diff --git a/data/users.db b/data/users.db index 038b7fa..0732016 100644 Binary files a/data/users.db and b/data/users.db differ diff --git a/src/components/app/app.tsx b/src/components/app/app.tsx index dec9cdb..f2cf1be 100644 --- a/src/components/app/app.tsx +++ b/src/components/app/app.tsx @@ -11,7 +11,6 @@ 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'; @@ -100,7 +99,6 @@ export function App() {
- diff --git a/src/components/buttons/delete-music/delete-music.module.css b/src/components/buttons/delete-music/delete-music.module.css new file mode 100644 index 0000000..f7bbd4c --- /dev/null +++ b/src/components/buttons/delete-music/delete-music.module.css @@ -0,0 +1,155 @@ +.deleteDropdownContainer { + position: relative; + display: inline-block; +} + +.deleteButton { + display: flex; + align-items: center; + justify-content: center; + gap: 4px; + width: 60px; + height: 32px; /* 与输入框内容区域高度一致 */ + padding: 6px 8px; + background: #ef4444; + color: white; + border: none; + border-radius: 6px; /* 与输入框圆角一致 */ + font-size: 12px; + font-weight: 500; + cursor: pointer; + transition: all 0.2s ease; + flex-shrink: 0; +} + +.deleteButton:hover:not(:disabled) { + background: #dc2626; + transform: translateY(-1px); +} + +.deleteButton:disabled { + background: var(--color-muted); + cursor: not-allowed; + opacity: 0.6; +} + +.deleteButton.disabled { + background: var(--color-muted); + cursor: not-allowed; + opacity: 0.6; +} + +.deleteDropdown { + position: absolute; + top: 100%; + right: 0; + margin-top: 8px; + width: 280px; + max-height: 320px; + background: var(--component-bg); + border: 1px solid var(--color-border); + border-radius: 8px; + box-shadow: 0 4px 12px rgba(0, 0, 0, 0.15); + z-index: 1000; + overflow: hidden; +} + +.dropdownHeader { + display: flex; + align-items: center; + justify-content: space-between; + padding: 12px 16px; + background: var(--bg-secondary); + border-bottom: 1px solid var(--color-border); +} + +.dropdownHeader h4 { + margin: 0; + font-size: 14px; + font-weight: 600; + color: var(--color-foreground); +} + +.closeButton { + background: none; + border: none; + color: var(--color-foreground-subtle); + font-size: 18px; + cursor: pointer; + padding: 0; + width: 24px; + height: 24px; + display: flex; + align-items: center; + justify-content: center; + border-radius: 4px; + transition: all 0.2s ease; +} + +.closeButton:hover { + background: var(--component-hover); + color: var(--color-foreground); +} + +.loading, +.empty { + padding: 16px; + text-align: center; + color: var(--color-foreground-subtle); + font-size: 14px; +} + +.musicList { + max-height: 240px; + overflow-y: auto; +} + +.musicItem { + display: flex; + align-items: center; + justify-content: space-between; + padding: 12px 16px; + border-bottom: 1px solid var(--color-border); + transition: background-color 0.2s ease; +} + +.musicItem:last-child { + border-bottom: none; +} + +.musicItem:hover { + background: var(--component-hover); +} + +.musicName { + flex: 1; + font-size: 14px; + color: var(--color-foreground); + margin-right: 12px; + overflow: hidden; + text-overflow: ellipsis; + white-space: nowrap; +} + +.deleteItemButton { + background: #ef4444; + color: white; + border: none; + border-radius: 4px; + padding: 6px 12px; + cursor: pointer; + font-size: 12px; + font-weight: 500; + transition: all 0.2s ease; + flex-shrink: 0; +} + +.deleteItemButton:hover:not(:disabled) { + background: #dc2626; +} + +.deleteItemButton:disabled { + background: var(--color-muted); + color: var(--color-foreground-subtle); + cursor: not-allowed; +} \ No newline at end of file diff --git a/src/components/buttons/delete-music/delete-music.tsx b/src/components/buttons/delete-music/delete-music.tsx new file mode 100644 index 0000000..bb5ac6e --- /dev/null +++ b/src/components/buttons/delete-music/delete-music.tsx @@ -0,0 +1,186 @@ +import { useState, useCallback, useEffect } from 'react'; +import { FaTrash } from 'react-icons/fa'; +import { useSoundStore } from '@/stores/sound'; +import { useAuthStore } from '@/stores/auth'; +import { useSnackbar } from '@/contexts/snackbar'; +import { useLocalizedSounds } from '@/hooks/useLocalizedSounds'; +import { ApiClient } from '@/lib/api-client'; +import { cn } from '@/helpers/styles'; + +import styles from './delete-music.module.css'; + +interface SavedMusic { + id: number; + name: string; + sounds: string[]; + created_at: string; +} + +export function DeleteMusicButton() { + const { isAuthenticated, user } = useAuthStore(); + const sounds = useSoundStore(state => state.sounds); + const selectedSoundIds = useSoundStore(state => + Object.keys(state.sounds).filter(id => state.sounds[id].isSelected) + ); + const showSnackbar = useSnackbar(); + const localizedCategories = useLocalizedSounds(); + + const [isDeleting, setIsDeleting] = useState(false); + const [showDeleteDropdown, setShowDeleteDropdown] = useState(false); + const [savedMusicList, setSavedMusicList] = useState([]); + const [isLoadingMusic, setIsLoadingMusic] = useState(false); + + // 获取选中的声音详细信息 + const selectedSounds = selectedSoundIds + .map(id => { + const allSounds = localizedCategories + .map(category => category.sounds) + .flat(); + return allSounds.find(sound => sound.id === id); + }) + .filter(Boolean); + + const noSelected = selectedSounds.length === 0; + const hasSelected = selectedSounds.length > 0; + + // 获取用户保存的音乐列表 + const fetchSavedMusic = useCallback(async () => { + if (!isAuthenticated || !user) { + setSavedMusicList([]); + return; + } + + setIsLoadingMusic(true); + + try { + const response = await ApiClient.post('/api/auth/music/list'); + + if (!response.ok) { + throw new Error('获取音乐列表失败'); + } + + const data = await response.json(); + if (data.success) { + setSavedMusicList(data.musicList || []); + } + } catch (error) { + console.error('❌ 获取音乐列表失败:', error); + setSavedMusicList([]); + } finally { + setIsLoadingMusic(false); + } + }, [isAuthenticated, user]); + + // 删除音乐 + const deleteMusic = useCallback(async (musicId: string, musicName: string) => { + if (!isAuthenticated || !user) return; + if (!confirm(`确定要删除"${musicName}"吗?`)) return; + + setIsDeleting(true); + + try { + const response = await ApiClient.post('/api/auth/music/delete', { + musicId + }); + + if (!response.ok) { + throw new Error('删除失败'); + } + + const data = await response.json(); + if (data.success) { + setSavedMusicList(prev => prev.filter(music => music.id !== parseInt(musicId))); + showSnackbar(`已删除音乐: ${musicName}`); + console.log('✅ 音乐删除成功'); + } else { + showSnackbar(data.error || '删除失败'); + } + } catch (error) { + console.error('❌ 删除音乐失败:', error); + showSnackbar('删除失败'); + } finally { + setIsDeleting(false); + } + }, [isAuthenticated, user, showSnackbar]); + + // 当用户认证状态改变时,获取音乐列表 + const handleToggleDropdown = useCallback(() => { + if (!isAuthenticated) { + showSnackbar('请先登录后再删除音乐'); + return; + } + + if (!showDeleteDropdown && savedMusicList.length === 0) { + fetchSavedMusic(); + } + setShowDeleteDropdown(!showDeleteDropdown); + }, [isAuthenticated, showDeleteDropdown, savedMusicList.length, fetchSavedMusic, showSnackbar]); + + // 点击外部关闭下拉菜单 + const handleDocumentClick = useCallback((event: MouseEvent) => { + const target = event.target as Element; + if (showDeleteDropdown && !target.closest(`.${styles.deleteDropdownContainer}`)) { + setShowDeleteDropdown(false); + } + }, [showDeleteDropdown]); + + // 添加和移除事件监听器 + useEffect(() => { + if (showDeleteDropdown) { + document.addEventListener('click', handleDocumentClick); + } + return () => { + document.removeEventListener('click', handleDocumentClick); + }; + }, [showDeleteDropdown, handleDocumentClick]); + + return ( +
+ + + {/* 删除下拉菜单 */} + {showDeleteDropdown && ( +
+
+

删除音乐

+ +
+ + {isLoadingMusic ? ( +
加载中...
+ ) : savedMusicList.length === 0 ? ( +
没有可删除的音乐
+ ) : ( +
+ {savedMusicList.map((music) => ( +
+ {music.name} + +
+ ))} +
+ )} +
+ )} +
+ ); +} \ No newline at end of file diff --git a/src/components/buttons/save-music/save-music.module.css b/src/components/buttons/save-music/save-music.module.css new file mode 100644 index 0000000..206d921 --- /dev/null +++ b/src/components/buttons/save-music/save-music.module.css @@ -0,0 +1,48 @@ +.saveButton { + display: flex; + align-items: center; + justify-content: center; + width: 60px; + height: 32px; /* 与输入框内容区域高度一致 (14px字体 + 8px*2 = 30px) */ + padding: 6px 8px; + font-family: var(--font-heading); + font-size: 12px; + line-height: 1; + color: var(--color-foreground); + cursor: pointer; + background-color: var(--bg-secondary); + border: 1px solid var(--color-border); + border-radius: 6px; /* 与输入框圆角一致 */ + transition: 0.2s; + gap: 4px; + flex-shrink: 0; + + &:hover:not(:disabled) { + background-color: var(--bg-tertiary); + } + + &:not(.disabled):active { + transform: scale(0.97); + } + + &:disabled, + &.disabled { + cursor: not-allowed; + opacity: 0.6; + } + + & span { + font-size: var(--font-base); + } + + &:focus-visible { + outline: 2px solid var(--color-muted); + outline-offset: 2px; + } + + &.saving { + background-color: var(--color-muted); + } +} + +/* 使用统一的 .loginPrompt 样式,定义在 sounds.module.css 中 */ \ No newline at end of file diff --git a/src/components/buttons/save-music/save-music.tsx b/src/components/buttons/save-music/save-music.tsx new file mode 100644 index 0000000..0f310e3 --- /dev/null +++ b/src/components/buttons/save-music/save-music.tsx @@ -0,0 +1,156 @@ +import { useState, useCallback } from 'react'; +import { FaSave } from 'react-icons/fa'; +import { useSoundStore } from '@/stores/sound'; +import { useAuthStore } from '@/stores/auth'; +import { useNotification } from '@/hooks/useNotification'; +import { useLocalizedSounds } from '@/hooks/useLocalizedSounds'; +import { Notification } from '@/components/notification/notification'; +import { ApiClient } from '@/lib/api-client'; +import { cn } from '@/helpers/styles'; + +import styles from './save-music.module.css'; +import soundsStyles from '@/components/sounds/sounds.module.css'; + +export function SaveMusicButton() { + const { isAuthenticated, user } = useAuthStore(); + const sounds = useSoundStore(state => state.sounds); + const selectedSoundIds = useSoundStore(state => + Object.keys(state.sounds).filter(id => state.sounds[id].isSelected) + ); + const { showNotificationMessage, ...notificationState } = useNotification(); + const localizedCategories = useLocalizedSounds(); + + const [isSaving, setIsSaving] = useState(false); + const [showLoginPrompt, setShowLoginPrompt] = useState(false); + + // 获取选中的声音详细信息 + const selectedSounds = selectedSoundIds + .map(id => { + const allSounds = localizedCategories + .map(category => category.sounds) + .flat(); + return allSounds.find(sound => sound.id === id); + }) + .filter(Boolean); + + const noSelected = selectedSounds.length === 0; + + // 获取音乐名称输入框的值 + const getMusicName = useCallback(() => { + const musicInput = document.querySelector('input[placeholder="音乐名称"]') as HTMLInputElement; + return musicInput?.value?.trim() || ''; + }, []); + + const handleSave = useCallback(async () => { + if (noSelected) return showNotificationMessage('请先选择声音', 'error'); + + if (!isAuthenticated) { + setShowLoginPrompt(true); + return; + } + + // 验证音乐名称输入 + const musicName = getMusicName(); + if (!musicName) { + showNotificationMessage('请输入音乐名称', 'error'); + const musicInput = document.querySelector('input[placeholder="音乐名称"]') as HTMLInputElement; + musicInput?.focus(); + return; + } + + setIsSaving(true); + + try { + // 准备保存的数据 + const volume: Record = {}; + const speed: Record = {}; + const rate: Record = {}; + const random_effects: Record = {}; + + selectedSounds.forEach(sound => { + if (sound) { + volume[sound.id] = sounds[sound.id]?.volume || 50; + speed[sound.id] = sounds[sound.id]?.speed || 1; + rate[sound.id] = sounds[sound.id]?.rate || 1; + random_effects[sound.id] = sounds[sound.id]?.isRandomSpeed || sounds[sound.id]?.isRandomVolume || sounds[sound.id]?.isRandomRate || false; + } + }); + + const musicData = { + name: musicName, + sounds: selectedSoundIds, + volume, + speed, + rate, + random_effects + }; + + const response = await ApiClient.post('/api/auth/music/save', musicData); + + if (response.ok) { + const result = await response.json(); + showNotificationMessage('音乐保存成功!', 'success'); + console.log('✅ 音乐保存成功:', result.music); + } else { + const errorData = await response.json(); + console.error('❌ 保存音乐失败:', errorData.error); + + if (response.status === 401) { + // JWT认证失败,显示登录提示 + setShowLoginPrompt(true); + } + showNotificationMessage(errorData.error || '保存失败', 'error'); + } + } catch (error) { + console.error('❌ 保存音乐失败:', error); + if (error instanceof Error && error.message.includes('401')) { + setShowLoginPrompt(true); + } + showNotificationMessage('保存失败,请重试', 'error'); + } finally { + setIsSaving(false); + } + }, [noSelected, isAuthenticated, user, selectedSounds, selectedSoundIds, sounds, showNotificationMessage, getMusicName]); + + return ( + <> + + + {/* 登录提示 */} + {showLoginPrompt && ( +
+

请先登录后再保存音乐

+ + +
+ )} + + + {/* 通用通知组件 */} + + + ); +} \ No newline at end of file diff --git a/src/components/notification/notification.module.css b/src/components/notification/notification.module.css new file mode 100644 index 0000000..7e743b7 --- /dev/null +++ b/src/components/notification/notification.module.css @@ -0,0 +1,90 @@ +.notification { + position: fixed; + top: 20px; + left: 50%; + transform: translateX(-50%); + z-index: 1002; + border-radius: 8px; + box-shadow: 0 4px 12px rgba(0, 0, 0, 0.15); + animation: notificationSlideIn 0.3s ease-out; + max-width: 400px; + width: calc(100vw - 40px); +} + +.notificationContent { + display: flex; + align-items: center; + justify-content: space-between; + padding: 12px 16px; + gap: 12px; +} + +.notificationMessage { + font-size: 14px; + color: white; + font-weight: 500; +} + +.notificationClose { + background: none; + border: none; + color: white; + font-size: 18px; + cursor: pointer; + padding: 0; + width: 20px; + height: 20px; + display: flex; + align-items: center; + justify-content: center; + border-radius: 2px; + transition: background-color 0.2s ease; + opacity: 0.7; +} + +.notificationClose:hover { + opacity: 1; +} + +/* 成功通知样式 */ +.notification.success { + background: linear-gradient(135deg, #10b981, #059669); + color: white; + border: 1px solid #059669; +} + +/* 错误通知样式 */ +.notification.error { + background: linear-gradient(135deg, #ef4444, #dc2626); + color: white; + border: 1px solid #dc2626; +} + +/* 通知动画 */ +@keyframes notificationSlideIn { + from { + transform: translateX(-50%) translateY(-100%); + opacity: 0; + } + to { + transform: translateX(-50%) translateY(0); + opacity: 1; + } +} + +/* 响应式设计 */ +@media (max-width: 768px) { + .notification { + top: 15px; + max-width: calc(100vw - 30px); + width: calc(100vw - 30px); + } + + .notificationContent { + padding: 10px 14px; + } + + .notificationMessage { + font-size: 13px; + } +} \ No newline at end of file diff --git a/src/components/notification/notification.tsx b/src/components/notification/notification.tsx new file mode 100644 index 0000000..be23ae9 --- /dev/null +++ b/src/components/notification/notification.tsx @@ -0,0 +1,32 @@ +import { AnimatePresence } from 'motion/react'; +import styles from './notification.module.css'; + +interface NotificationProps { + show: boolean; + message: string; + type: 'success' | 'error'; + onClose: () => void; +} + +export function Notification({ show, message, type, onClose }: NotificationProps) { + return ( + + {show && ( +
+
+ + {message} + + +
+
+ )} +
+ ); +} \ 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 index d5e7abb..9539d8d 100644 --- a/src/components/saved-music-list/saved-music-list.tsx +++ b/src/components/saved-music-list/saved-music-list.tsx @@ -5,6 +5,7 @@ import { AnimatePresence } from 'motion/react'; import { useAuthStore } from '@/stores/auth'; import { useSoundStore } from '@/stores/sound'; import { useTranslation } from '@/hooks/useTranslation'; +import { ApiClient } from '@/lib/api-client'; import type { SavedMusic } from '@/lib/database'; @@ -16,7 +17,7 @@ interface SavedMusicListProps { export function SavedMusicList({ onMusicSelect }: SavedMusicListProps) { const { t } = useTranslation(); - const { isAuthenticated, user, sessionPassword } = useAuthStore(); + const { isAuthenticated, user } = useAuthStore(); const [savedMusicList, setSavedMusicList] = useState([]); const [loading, setLoading] = useState(false); const [editingId, setEditingId] = useState(null); @@ -36,22 +37,13 @@ export function SavedMusicList({ onMusicSelect }: SavedMusicListProps) { // 获取用户保存的音乐列表 const fetchSavedMusic = async () => { - if (!isAuthenticated || !user || !sessionPassword) return; + if (!isAuthenticated || !user) 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, // 使用会话密码 - }), - }); + const response = await ApiClient.post('/api/auth/music/list'); if (!response.ok) { throw new Error('获取音乐列表失败'); @@ -76,17 +68,9 @@ export function SavedMusicList({ onMusicSelect }: SavedMusicListProps) { 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, - }), + const response = await ApiClient.post('/api/auth/music/rename', { + musicId, + name: newName }); if (!response.ok) { @@ -121,16 +105,8 @@ export function SavedMusicList({ onMusicSelect }: SavedMusicListProps) { } 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, - }), + const response = await ApiClient.post('/api/auth/music/delete', { + musicId }); if (!response.ok) { @@ -221,12 +197,12 @@ export function SavedMusicList({ onMusicSelect }: SavedMusicListProps) { // 当用户认证状态改变时,获取音乐列表 useEffect(() => { - if (isAuthenticated && user && sessionPassword) { + if (isAuthenticated && user) { fetchSavedMusic(); } else { setSavedMusicList([]); } - }, [isAuthenticated, user, sessionPassword]); + }, [isAuthenticated, user]); // 如果用户未登录,不显示组件 if (!isAuthenticated) { diff --git a/src/components/selected-sounds-display/selected-sounds-display.tsx b/src/components/selected-sounds-display/selected-sounds-display.tsx index e0f2b08..be40784 100644 --- a/src/components/selected-sounds-display/selected-sounds-display.tsx +++ b/src/components/selected-sounds-display/selected-sounds-display.tsx @@ -1,15 +1,30 @@ -import { useMemo, useState } from 'react'; -import { AnimatePresence } from 'motion/react'; -import { FaSave } from 'react-icons/fa/index'; +import { useMemo, useState, useEffect } from 'react'; +import { AnimatePresence, motion } from 'motion/react'; +import { FaSave, FaPlay, FaTrash, FaEdit, FaCog, FaSignOutAlt, FaMusic } from 'react-icons/fa/index'; +import { SaveMusicButton } from '@/components/buttons/save-music/save-music'; +import { DeleteMusicButton } from '@/components/buttons/delete-music/delete-music'; import { useSoundStore } from '@/stores/sound'; import { useLocalizedSounds } from '@/hooks/useLocalizedSounds'; import { useTranslation } from '@/hooks/useTranslation'; import { useAuthStore } from '@/stores/auth'; +import { ApiClient } from '@/lib/api-client'; import { Sound } from '@/components/sounds/sound'; import styles from '../sounds/sounds.module.css'; +interface SavedMusic { + id: number; + name: string; + sounds: string[]; + volume: Record; + speed: Record; + rate: Record; + random_effects: Record; + created_at: string; + updated_at: string; +} + export function SelectedSoundsDisplay() { const { t } = useTranslation(); const localizedCategories = useLocalizedSounds(); @@ -17,6 +32,14 @@ export function SelectedSoundsDisplay() { const [isSaving, setIsSaving] = useState(false); const [showLoginPrompt, setShowLoginPrompt] = useState(false); const [showSaveSuccess, setShowSaveSuccess] = useState(false); + const [savedMusicList, setSavedMusicList] = useState([]); + const [isLoadingMusic, setIsLoadingMusic] = useState(false); + const [editingId, setEditingId] = useState(null); + const [editingName, setEditingName] = useState(''); + const [showMusicDropdown, setShowMusicDropdown] = useState(true); // 默认展开 + const [expandedMusic, setExpandedMusic] = useState>(new Set()); // 跟踪展开的音乐项 + const [error, setError] = useState(null); + const [musicName, setMusicName] = useState(''); // 获取声音store const sounds = useSoundStore(state => state.sounds); @@ -26,6 +49,217 @@ export function SelectedSoundsDisplay() { Object.keys(state.sounds).filter(id => state.sounds[id].isSelected) ); + // 获取声音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 () => { + console.log('🔍 fetchSavedMusic 被调用'); + console.log('🔐 认证状态:', { isAuthenticated, user: user?.username }); + + if (!isAuthenticated || !user) { + console.log('❌ 用户未认证,退出获取音乐列表'); + setSavedMusicList([]); + return; + } + + setIsLoadingMusic(true); + setError(null); + + try { + console.log('🔍 开始获取音乐列表,用户:', user.username); + + // 检查localStorage中的token + const authStorage = localStorage.getItem('auth-storage'); + console.log('🗄️ localStorage中的auth-storage:', authStorage); + if (authStorage) { + try { + const parsed = JSON.parse(authStorage); + console.log('🔑 parsed state token:', parsed.state?.token ? '存在' : '不存在'); + console.log('🔑 parsed state user:', parsed.state?.user?.username); + } catch (e) { + console.error('解析auth-storage失败:', e); + } + } + + // 检查store中的token + const storeToken = useAuthStore.getState().getToken(); + console.log('🏪 store中的token:', storeToken ? '存在' : '不存在'); + + const response = await ApiClient.post('/api/auth/music/list'); + + console.log('📡 音乐列表API响应状态:', response.status); + console.log('📡 响应头:', response.headers); + + if (!response.ok) { + const errorText = await response.text(); + console.error('❌ API响应错误:', response.status, errorText); + throw new Error(`获取音乐列表失败 (${response.status}): ${errorText}`); + } + + const data = await response.json(); + console.log('📋 音乐列表数据:', data); + + if (data.success) { + console.log('✅ 设置音乐列表:', data.musicList || [], '数量:', (data.musicList || []).length); + setSavedMusicList(data.musicList || []); + console.log('✅ savedMusicList状态更新完成'); + } else { + setError(data.error || '获取音乐列表失败'); + console.error('❌ 音乐列表API返回错误:', data.error); + } + } catch (error) { + console.error('❌ 获取音乐列表失败:', error); + setError('获取音乐列表失败,请稍后再试'); + setSavedMusicList([]); + } finally { + setIsLoadingMusic(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 === parseInt(musicId) ? { ...music, name: newName } : music + ) + ); + setEditingId(null); + setEditingName(''); + console.log('✅ 音乐重命名成功'); + } else { + setError(data.error || '重命名失败'); + } + } catch (error) { + console.error('❌ 重命名音乐失败:', error); + 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 !== parseInt(musicId))); + console.log('✅ 音乐删除成功'); + } else { + setError(data.error || '删除失败'); + } + } catch (error) { + console.error('❌ 删除音乐失败:', error); + setError('删除失败'); + } + }; + + // 切换音乐展开状态 + const toggleMusicExpansion = (musicId: number) => { + setExpandedMusic(prev => { + const newSet = new Set(prev); + if (newSet.has(musicId)) { + newSet.delete(musicId); + } else { + newSet.add(musicId); + } + return newSet; + }); + }; + + // 播放保存的音乐 + 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); + + // 设置速度 + 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(); + + console.log('✅ 开始播放音乐:', music.name); + }, 100); + }; + // 保存音乐功能 const saveMusic = async () => { if (!isAuthenticated) { @@ -33,6 +267,12 @@ export function SelectedSoundsDisplay() { return; } + if (selectedSoundIds.length === 0) { + setError('请先选择声音'); + setTimeout(() => setError(null), 3000); + return; + } + setIsSaving(true); try { @@ -50,23 +290,15 @@ export function SelectedSoundsDisplay() { 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()}`, + name: musicName || `我的音乐 ${new Date().toLocaleDateString()}`, sounds: selectedSoundIds, volume, speed, rate, random_effects, username: user?.username, - password: sessionPassword // 使用会话密码 + password: sessionPassword || '' // 使用会话密码,如果为空则让后端处理 }; // 调用保存API @@ -81,8 +313,9 @@ export function SelectedSoundsDisplay() { if (response.ok) { const result = await response.json(); setShowSaveSuccess(true); - setTimeout(() => setShowSaveSuccess(false), 2000); console.log('✅ 音乐保存成功:', result.music); + // 保存成功后刷新列表 + await fetchSavedMusic(); } else { const errorData = await response.json(); console.error('❌ 保存音乐失败:', errorData.error); @@ -90,11 +323,15 @@ export function SelectedSoundsDisplay() { if (response.status === 401) { setShowLoginPrompt(true); } + setError(errorData.error || '保存失败'); + setTimeout(() => setError(null), 3000); } } catch (error) { console.error('❌ 保存音乐失败:', error); // 网络错误或其他异常,显示登录提示 setShowLoginPrompt(true); + setError('保存失败,请重试'); + setTimeout(() => setError(null), 3000); } finally { setIsSaving(false); } @@ -111,6 +348,26 @@ export function SelectedSoundsDisplay() { .filter(Boolean); }, [selectedSoundIds, localizedCategories]); + // 当用户认证状态改变时,获取音乐列表 + useEffect(() => { + if (isAuthenticated && user) { + fetchSavedMusic(); + } else { + setSavedMusicList([]); + } + }, [isAuthenticated, user]); + + // 当用户认证状态改变时,获取音乐列表 + useEffect(() => { + if (isAuthenticated && user) { + console.log('🎵 用户已登录,自动获取音乐列表...'); + fetchSavedMusic(); + } else { + setSavedMusicList([]); + } + }, [isAuthenticated, user]); + + // 如果没有选中任何声音,不显示组件 if (selectedSounds.length === 0) { return null; @@ -118,6 +375,23 @@ export function SelectedSoundsDisplay() { return (
+ {/* 音乐名称配置区域 */} + {selectedSounds.length > 0 && ( +
+ setMusicName(e.target.value)} + placeholder="音乐名称" + className={styles.musicNameInput} + maxLength={50} + /> + + +
+ )} + + {/* 选中的声音展示 */}
{selectedSounds.map((sound) => ( @@ -137,47 +411,170 @@ export function SelectedSoundsDisplay() {
- {/* 保存按钮区域 */} -
- - - {/* 保存成功提示 */} - {showSaveSuccess && ( -
- ✓ 音乐保存成功! + {/* 音乐列表区域 - 只有登录用户才显示 */} + {isAuthenticated && ( +
+
+

+ + 我的音乐 +

- )} - {/* 登录提示 */} - {showLoginPrompt && ( -
-

请先登录后再保存音乐

- - + {/* 错误提示 */} + {error && ( +
+ {error} + +
+ )} + + {/* 保存成功提示 */} + {showSaveSuccess && ( +
+

✓ 音乐保存成功!

+ +
+ )} + + {/* 音乐列表 - 自动显示 */} +
+ {console.log('🎵 渲染音乐列表:', { isLoadingMusic, listLength: savedMusicList.length })} + {isLoadingMusic ? ( +
加载中...
+ ) : savedMusicList.length === 0 ? ( +
+ +

还没有保存的音乐

+

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

+
+ ) : ( + + {savedMusicList.map((music) => ( +
+ {editingId === music.id.toString() ? ( +
+ setEditingName(e.target.value)} + className={styles.editInput} + placeholder="输入音乐名称" + maxLength={50} + /> +
+ + +
+
+ ) : ( +
+ +
+
+ { + setEditingId(music.id.toString()); + setEditingName(music.name); + }} + title="点击编辑名称" + > + {music.name} + + +
+ {/* 展开时显示收录的声音名字 */} + {expandedMusic.has(music.id) && ( +
+ {music.sounds && music.sounds.length > 0 ? ( + music.sounds.map((soundId: string, index: number) => { + // 从所有声音中查找对应的声音名称 + const allSounds = localizedCategories + .map(category => category.sounds) + .flat(); + const sound = allSounds.find(s => s.id === soundId); + return sound ? ( + + {sound.label}{index < music.sounds.length - 1 ? ', ' : ''} + + ) : null; + }) + ) : ( + 暂无声音 + )} +
+ )} +
+ +
+ )} +
+ ))} +
+ )}
- )} -
+
+ )} + + {/* 登录提示 */} + {showLoginPrompt && ( +
+

请先登录后再保存音乐

+ + +
+ )}
); } \ No newline at end of file diff --git a/src/components/slider/slider.module.css b/src/components/slider/slider.module.css index cb713e3..c336081 100644 --- a/src/components/slider/slider.module.css +++ b/src/components/slider/slider.module.css @@ -27,13 +27,15 @@ width: 16px; height: 16px; cursor: pointer; - background: var(--color-neutral-950); + background: var(--bg-tertiary); + border: 1px solid var(--color-border); border-radius: 50%; box-shadow: 0 0 3px var(--color-neutral-50); } .sliderThumb:hover { - background: var(--color-neutral-800); + background: var(--bg-secondary); + border-color: var(--color-foreground); } .sliderThumb:focus { diff --git a/src/components/sounds/sound/range/range.module.css b/src/components/sounds/sound/range/range.module.css index e36bb7f..444bfc8 100644 --- a/src/components/sounds/sound/range/range.module.css +++ b/src/components/sounds/sound/range/range.module.css @@ -74,9 +74,10 @@ height: 14px; margin-top: -3px; appearance: none; - background-color: var(--bg-tertiary); + background-color: var(--color-neutral-700); border: 1px solid var(--color-border); border-radius: 50%; + box-shadow: 0 0 2px var(--color-neutral-400); } &:not(:disabled):focus::-webkit-slider-thumb { @@ -97,11 +98,12 @@ width: 14px; height: 14px; margin-top: -3px; - background-color: var(--bg-tertiary); + background-color: var(--color-neutral-700); border: none; border: 1px solid var(--color-border); border-radius: 0; border-radius: 50%; + box-shadow: 0 0 2px var(--color-neutral-400); } &:not(:disabled):focus::-moz-range-thumb { diff --git a/src/components/sounds/sounds.module.css b/src/components/sounds/sounds.module.css index 6ac1f0f..5639afa 100644 --- a/src/components/sounds/sounds.module.css +++ b/src/components/sounds/sounds.module.css @@ -8,8 +8,12 @@ .soundsContainer { display: flex; flex-direction: column; - gap: 20px; + gap: 16px; margin-top: 20px; + background: var(--bg-secondary); + border-radius: 12px; + border: 1px solid var(--color-border); + padding: 16px; } .saveSection { @@ -28,7 +32,7 @@ display: flex; align-items: center; gap: 8px; - padding: 12px 20px; + padding: 10px 16px; background: linear-gradient(135deg, #10b981, #059669); color: white; border: none; @@ -37,6 +41,346 @@ font-weight: 500; cursor: pointer; transition: all 0.2s ease; + min-height: 40px; +} + +/* 音乐管理区域 */ +.musicSection { + border-top: 1px solid var(--color-border); + padding-top: 16px; +} + +.musicHeader { + display: flex; + align-items: center; + justify-content: space-between; + margin-bottom: 12px; +} + +.musicTitle { + display: flex; + align-items: center; + gap: 8px; + margin: 0; + font-size: 16px; + font-weight: 600; + color: var(--color-foreground); +} + +.musicIcon { + color: var(--color-foreground-subtle); + font-size: 14px; +} + +.toggleMusicList { + background: transparent; + border: 1px solid var(--color-border); + color: var(--color-foreground-subtle); + border-radius: 4px; + padding: 4px 8px; + font-size: 12px; + cursor: pointer; + transition: all 0.2s ease; +} + +.toggleMusicList:hover { + background: var(--component-hover); + border-color: var(--color-foreground-subtle); + color: var(--color-foreground); +} + +.musicList { + max-height: 300px; + overflow-y: auto; + border-radius: 6px; + background: var(--bg-tertiary); + border: 1px solid var(--color-border); +} + +.musicItem { + padding: 12px; + border-bottom: 1px solid var(--color-border); + transition: background-color 0.2s ease; +} + +.musicItem:last-child { + border-bottom: none; +} + +.musicItem:hover { + background: var(--component-hover); +} + +.musicContent { + display: flex; + align-items: center; + gap: 8px; +} + +.playButton { + background: var(--color-foreground); + color: var(--bg-primary); + border: none; + border-radius: 4px; + padding: 6px 8px; + cursor: pointer; + font-size: 12px; + transition: all 0.2s ease; + flex-shrink: 0; +} + +.playButton:hover { + background: var(--color-foreground-subtle); +} + +.musicName { + flex: 1; + font-size: 14px; + color: var(--color-foreground); + cursor: pointer; + padding: 4px 8px; + border-radius: 4px; + transition: background-color 0.2s ease; + min-width: 0; + overflow: hidden; + text-overflow: ellipsis; + white-space: nowrap; +} + +.musicName:hover { + background: var(--component-hover); +} + +/* 音乐信息容器 */ +.musicInfo { + flex: 1; + display: flex; + flex-direction: column; + min-width: 0; + gap: 2px; +} + +/* 音乐名称行 */ +.musicNameRow { + display: flex; + align-items: center; + justify-content: space-between; + gap: 8px; +} + +/* 展开按钮 */ +.expandButton { + background: transparent; + border: 1px solid var(--color-border); + color: var(--color-foreground-subtle); + border-radius: 4px; + padding: 2px 6px; + font-size: 10px; + cursor: pointer; + transition: all 0.2s ease; + flex-shrink: 0; + height: 20px; + line-height: 1; +} + +.expandButton:hover { + background: var(--component-hover); + border-color: var(--color-foreground-subtle); + color: var(--color-foreground); +} + +/* 声音名字显示 */ +.soundNames { + font-size: 12px; + color: var(--color-foreground-subtle); + line-height: 1.3; + word-break: break-all; +} + +.soundName { + color: var(--color-foreground-subtle); +} + +.noSounds { + color: var(--color-foreground-subtler); + font-style: italic; +} + +.deleteButton { + background: #ef4444; + color: white; + border: none; + border-radius: 4px; + padding: 6px 8px; + cursor: pointer; + font-size: 12px; + transition: all 0.2s ease; + flex-shrink: 0; +} + +.deleteButton:hover { + background: #dc2626; +} + +/* 编辑表单样式 */ +.editForm { + display: flex; + align-items: center; + gap: 8px; + padding: 4px 0; +} + +.editInput { + flex: 1; + padding: 6px 8px; + border: 1px solid var(--color-border); + border-radius: 4px; + font-size: 14px; + background: var(--input-bg); + color: var(--color-foreground); + outline: none; +} + +.editInput:focus { + border-color: var(--color-muted); + box-shadow: 0 0 0 2px var(--color-muted); +} + +.editButtons { + display: flex; + gap: 4px; +} + +.editButton { + border: none; + border-radius: 4px; + padding: 6px 8px; + cursor: pointer; + font-size: 12px; + font-weight: 500; + transition: all 0.2s ease; + width: 28px; + height: 28px; + display: flex; + align-items: center; + justify-content: center; +} + +.editButton.saveButton { + background: #10b981; + color: white; +} + +.editButton.saveButton:hover { + background: #059669; +} + +.editButton.cancelButton { + background: var(--color-muted); + color: var(--color-foreground); +} + +.editButton.cancelButton:hover { + background: var(--color-foreground-subtle); +} + +/* 音乐名称配置区域 */ +.musicNameConfig { + display: flex; + align-items: center; + gap: 12px; + margin-bottom: 16px; + /* 移除边框和背景 */ + padding: 0; + background: transparent; + border: none; + border-radius: 0; +} + +.musicNameInput { + width: 6.25em; /* 5em * 1.25 = 6.25em,增大1/4 */ + padding: 8px 12px; + border: 1px solid var(--color-border); + border-radius: 6px; + font-size: 14px; + background: var(--input-bg); + color: var(--color-foreground); + outline: none; + transition: all 0.2s ease; + flex-shrink: 0; +} + +.musicNameInput:focus { + border-color: var(--color-muted); + box-shadow: 0 0 0 2px var(--color-muted); +} + +.musicNameInput::placeholder { + color: var(--color-foreground-subtler); +} + +/* 空状态和加载状态 */ +.empty { + padding: 24px; + text-align: center; + color: var(--color-foreground-subtle); +} + +.emptyIcon { + font-size: 24px; + color: var(--color-foreground-subtler); + margin-bottom: 8px; +} + +.empty p { + margin: 4px 0; + font-size: 14px; +} + +.emptyHint { + font-size: 12px !important; + color: var(--color-foreground-subtler) !important; +} + +.loading { + padding: 16px; + text-align: center; + color: var(--color-foreground-subtle); + font-size: 14px; +} + +/* 错误提示样式 */ +.error { + background: rgba(239, 68, 68, 0.1); + border: 1px solid #ef4444; + color: #dc2626; + padding: 8px 12px; + border-radius: 4px; + font-size: 13px; + display: flex; + align-items: center; + justify-content: space-between; + margin-bottom: 12px; +} + +.errorClose { + background: none; + border: none; + color: #dc2626; + font-size: 16px; + cursor: pointer; + padding: 0; + width: 20px; + height: 20px; + display: flex; + align-items: center; + justify-content: center; + border-radius: 2px; + transition: background-color 0.2s ease; +} + +.errorClose:hover { + background: rgba(239, 68, 68, 0.2); } .saveButton:hover:not(:disabled) { @@ -56,13 +400,48 @@ } .saveSuccess { + position: fixed; + top: 50%; + left: 50%; + transform: translate(-50%, -50%); + 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); + z-index: 1002; + min-width: 250px; + font-size: 14px; + animation: slideIn 0.3s ease-out; +} + +.saveSuccess p { + margin: 0; + font-size: 14px; + color: var(--color-foreground-subtle); +} + +.saveSuccess button { padding: 8px 16px; - background: linear-gradient(135deg, #10b981, #059669); - color: white; - border-radius: 6px; + margin: 0 4px; + border: none; + border-radius: 4px; font-size: 13px; font-weight: 500; - animation: slideIn 0.3s ease-out; + cursor: pointer; + transition: all 0.2s ease; + background: var(--color-foreground); + color: var(--bg-primary); +} + +.saveSuccess button:hover { + background: var(--color-foreground-subtle); } .loginPrompt { diff --git a/src/hooks/useNotification.ts b/src/hooks/useNotification.ts new file mode 100644 index 0000000..d0f884b --- /dev/null +++ b/src/hooks/useNotification.ts @@ -0,0 +1,38 @@ +import { useState } from 'react'; + +interface NotificationState { + showNotification: boolean; + notificationMessage: string; + notificationType: 'success' | 'error'; +} + +export function useNotification() { + const [state, setState] = useState({ + showNotification: false, + notificationMessage: '', + notificationType: 'success' + }); + + const showNotificationMessage = (message: string, type: 'success' | 'error') => { + setState({ + showNotification: true, + notificationMessage: message, + notificationType: type + }); + + // 3秒后自动关闭 + setTimeout(() => { + setState(prev => ({ ...prev, showNotification: false })); + }, 3000); + }; + + const hideNotification = () => { + setState(prev => ({ ...prev, showNotification: false })); + }; + + return { + ...state, + showNotificationMessage, + hideNotification + }; +} \ No newline at end of file diff --git a/src/lib/api-client.ts b/src/lib/api-client.ts new file mode 100644 index 0000000..ff461f1 --- /dev/null +++ b/src/lib/api-client.ts @@ -0,0 +1,135 @@ +import { useAuthStore } from '@/stores/auth'; + +/** + * API客户端辅助函数,自动添加JWT Authorization头 + */ +export class ApiClient { + /** + * 发起API请求 + * @param url - API URL + * @param options - fetch options + * @returns Promise + */ + static async fetch(url: string, options: RequestInit = {}): Promise { + // 获取token - 尝试多种方式获取Zustand store + let token = null; + + try { + // 方法1: 通过useAuthStore.getState()获取 + token = useAuthStore.getState().getToken(); + console.log('🔐 方法1获取token结果:', token ? '成功' : '失败'); + } catch (e) { + console.warn('无法通过useAuthStore.getState()获取token:', e); + } + + // 如果方法1失败,尝试方法2: 从localStorage直接获取 + if (!token) { + try { + const authStorage = localStorage.getItem('auth-storage'); + console.log('🔐 localStorage auth-storage:', authStorage ? '存在' : '不存在'); + if (authStorage) { + const parsed = JSON.parse(authStorage); + token = parsed.state?.token; + console.log('🔐 方法2获取token结果:', token ? '成功' : '失败'); + } + } catch (e) { + console.warn('无法从localStorage获取token:', e); + } + } + + // 创建新的headers对象 + const headers = new Headers(options.headers || {}); + + // 添加Content-Type(如果没有的话) + if (!headers.has('Content-Type') && (options.method === 'POST' || options.method === 'PUT' || options.method === 'PATCH')) { + headers.set('Content-Type', 'application/json'); + } + + // 添加Authorization头(如果有token) + if (token) { + headers.set('Authorization', `Bearer ${token}`); + console.log('🔑 已添加Authorization头,URL:', url); + } else { + console.warn('⚠️ 没有找到token,请求URL:', url); + } + + // 发起请求 + const response = await fetch(url, { + ...options, + headers, + }); + + console.log('📡 API响应:', url, response.status); + return response; + } + + /** + * 发起POST请求 + * @param url - API URL + * @param data - 请求数据 + * @returns Promise + */ + static async post(url: string, data?: any): Promise { + return this.fetch(url, { + method: 'POST', + body: data ? JSON.stringify(data) : undefined, + }); + } + + /** + * 发起GET请求 + * @param url - API URL + * @returns Promise + */ + static async get(url: string): Promise { + return this.fetch(url, { + method: 'GET', + }); + } + + /** + * 发起PUT请求 + * @param url - API URL + * @param data - 请求数据 + * @returns Promise + */ + static async put(url: string, data?: any): Promise { + return this.fetch(url, { + method: 'PUT', + body: data ? JSON.stringify(data) : undefined, + }); + } + + /** + * 发起DELETE请求 + * @param url - API URL + * @returns Promise + */ + static async delete(url: string): Promise { + return this.fetch(url, { + method: 'DELETE', + }); + } +} + +/** + * 简化的API调用函数 + * @param url - API URL + * @param data - 请求数据 + * @param method - HTTP方法 + * @returns Promise + */ +export async function apiCall(url: string, data?: any, method: 'POST' | 'GET' | 'PUT' | 'DELETE' = 'POST'): Promise { + const response = await ApiClient.fetch(url, { + method, + body: data ? JSON.stringify(data) : undefined, + }); + + const result = await response.json(); + + if (!response.ok) { + throw new Error(result.error || 'API 调用失败'); + } + + return result; +} \ No newline at end of file diff --git a/src/lib/auth-middleware.ts b/src/lib/auth-middleware.ts new file mode 100644 index 0000000..d2ecc95 --- /dev/null +++ b/src/lib/auth-middleware.ts @@ -0,0 +1,166 @@ +import type { APIRoute } from 'astro'; +import { authenticateUser } from '@/lib/database'; + +/** + * 认证中间件 - 统一处理用户身份验证 + * @param request - Astro请求对象 + * @returns 认证结果对象 + */ +export interface AuthResult { + success: boolean; + user?: { + id: number; + username: string; + }; + error?: { + message: string; + status: number; + }; + data?: any; +} + +/** + * 验证请求并解析JSON数据 + * @param request - Astro请求对象 + * @param requiredFields - 必需的字段数组 + * @returns 认证结果 + */ +export async function authenticateRequest( + request: Request, + requiredFields: string[] = ['username', 'password'] +): Promise { + try { + // 验证请求体 + const body = await request.text(); + if (!body.trim()) { + return { + success: false, + error: { + message: '请求体不能为空', + status: 400 + } + }; + } + + // 解析JSON + let data; + try { + data = JSON.parse(body); + } catch (parseError) { + return { + success: false, + error: { + message: '请求格式错误,请检查JSON格式', + status: 400 + } + }; + } + + // 验证必需字段 + const missingFields = requiredFields.filter(field => { + // 对于密码字段,我们允许空字符串,但不允许undefined/null + if (field === 'password') { + return data[field] === undefined || data[field] === null; + } + return !data[field]; + }); + if (missingFields.length > 0) { + return { + success: false, + error: { + message: `缺少必需字段: ${missingFields.join(', ')}`, + status: 400 + } + }; + } + + // 验证用户身份 + const user = authenticateUser(data.username, data.password); + if (!user) { + return { + success: false, + error: { + message: '用户认证失败,请检查用户名和密码', + status: 401 + } + }; + } + + return { + success: true, + user: { + id: user.id, + username: user.username + }, + data + }; + + } catch (error) { + console.error('认证过程出错:', error); + return { + success: false, + error: { + message: '服务器内部错误', + status: 500 + } + }; + } +} + +/** + * 创建标准化的API响应 + * @param success - 是否成功 + * @param data - 响应数据 + * @param message - 响应消息 + * @param status - HTTP状态码 + * @returns Response对象 + */ +export function createApiResponse( + success: boolean, + data?: any, + message?: string, + status: number = 200 +): Response { + const responseBody = { + success, + ...(message && { message }), + ...(data && data) + }; + + return new Response(JSON.stringify(responseBody), { + status, + headers: { 'Content-Type': 'application/json' } + }); +} + +/** + * 创建错误响应 + * @param message - 错误消息 + * @param status - HTTP状态码 + * @returns Response对象 + */ +export function createErrorResponse(message: string, status: number = 500): Response { + return createApiResponse(false, undefined, message, status); +} + +/** + * 处理API错误的统一函数 + * @param error - 错误对象 + * @param operation - 操作描述 + * @returns Response对象 + */ +export function handleApiError(error: unknown, operation: string): Response { + console.error(`${operation}错误:`, error); + + let errorMessage = `${operation}失败,请稍后再试`; + let status = 500; + + if (error instanceof SyntaxError && error.message.includes('JSON')) { + errorMessage = '请求格式错误'; + status = 400; + } else if (error instanceof Error) { + errorMessage = error.message; + } + + return createErrorResponse(errorMessage, status); +} \ No newline at end of file diff --git a/src/lib/jwt-auth-middleware.ts b/src/lib/jwt-auth-middleware.ts new file mode 100644 index 0000000..68a531f --- /dev/null +++ b/src/lib/jwt-auth-middleware.ts @@ -0,0 +1,195 @@ +import type { APIRoute } from 'astro'; +import { verifyJWT, extractTokenFromHeader } from '@/lib/jwt'; + +export interface JWTAuthResult { + success: boolean; + user?: { + id: number; + username: string; + }; + error?: { + message: string; + status: number; + }; + data?: any; +} + +/** + * JWT认证中间件 - 验证请求中的JWT Token + * @param request - Astro请求对象 + * @returns 认证结果对象 + */ +export async function authenticateJWTRequest(request: Request): Promise { + try { + // 从Authorization头中提取token + const authHeader = request.headers.get('Authorization'); + const token = extractTokenFromHeader(authHeader); + + if (!token) { + return { + success: false, + error: { + message: '缺少授权令牌,请先登录', + status: 401 + } + }; + } + + // 验证JWT token + const payload = verifyJWT(token); + if (!payload) { + return { + success: false, + error: { + message: '授权令牌无效或已过期,请重新登录', + status: 401 + } + }; + } + + return { + success: true, + user: { + id: payload.userId, + username: payload.username + } + }; + + } catch (error) { + console.error('JWT认证过程出错:', error); + return { + success: false, + error: { + message: '认证服务异常', + status: 500 + } + }; + } +} + +/** + * 验证请求体并解析JSON数据 + * @param request - Astro请求对象 + * @param requiredFields - 必需的字段数组 + * @returns 解析结果 + */ +export async function parseRequestBody( + request: Request, + requiredFields: string[] = [] +): Promise<{ success: boolean; data?: any; error?: { message: string; status: number } }> { + try { + // 验证请求体 + const body = await request.text(); + if (!body.trim()) { + return { + success: false, + error: { + message: '请求体不能为空', + status: 400 + } + }; + } + + // 解析JSON + let data; + try { + data = JSON.parse(body); + } catch (parseError) { + return { + success: false, + error: { + message: '请求格式错误,请检查JSON格式', + status: 400 + } + }; + } + + // 验证必需字段 + const missingFields = requiredFields.filter(field => !data[field]); + if (missingFields.length > 0) { + return { + success: false, + error: { + message: `缺少必需字段: ${missingFields.join(', ')}`, + status: 400 + } + }; + } + + return { + success: true, + data + }; + + } catch (error) { + console.error('请求体解析出错:', error); + return { + success: false, + error: { + message: '服务器内部错误', + status: 500 + } + }; + } +} + +/** + * 创建标准化的API响应 + * @param success - 是否成功 + * @param data - 响应数据 + * @param message - 响应消息 + * @param status - HTTP状态码 + * @returns Response对象 + */ +export function createApiResponse( + success: boolean, + data?: any, + message?: string, + status: number = 200 +): Response { + const responseBody = { + success, + ...(message && { message }), + ...(data && data) + }; + + return new Response(JSON.stringify(responseBody), { + status, + headers: { 'Content-Type': 'application/json' } + }); +} + +/** + * 创建错误响应 + * @param message - 错误消息 + * @param status - HTTP状态码 + * @returns Response对象 + */ +export function createErrorResponse(message: string, status: number = 500): Response { + return new Response(JSON.stringify({ error: message }), { + status, + headers: { 'Content-Type': 'application/json' } + }); +} + +/** + * 处理API错误的统一函数 + * @param error - 错误对象 + * @param operation - 操作描述 + * @returns Response对象 + */ +export function handleApiError(error: unknown, operation: string): Response { + console.error(`${operation}错误:`, error); + + let errorMessage = `${operation}失败,请稍后再试`; + let status = 500; + + if (error instanceof SyntaxError && error.message.includes('JSON')) { + errorMessage = '请求格式错误'; + status = 400; + } else if (error instanceof Error) { + errorMessage = error.message; + } + + return createErrorResponse(errorMessage, status); +} \ No newline at end of file diff --git a/src/lib/jwt.ts b/src/lib/jwt.ts new file mode 100644 index 0000000..bbaa48e --- /dev/null +++ b/src/lib/jwt.ts @@ -0,0 +1,118 @@ +import crypto from 'crypto'; + +// JWT密钥 - 在生产环境中应该使用环境变量 +const JWT_SECRET = process.env.JWT_SECRET || 'your-secret-key-change-in-production'; +const JWT_ALGORITHM = 'HS256'; +const JWT_EXPIRES_IN = 7 * 24 * 60 * 60; // 7天过期 + +export interface JWTPayload { + userId: number; + username: string; + iat?: number; + exp?: number; +} + +/** + * 创建JWT Token + */ +export function createJWT(payload: Omit): string { + const header = { + alg: JWT_ALGORITHM, + typ: 'JWT' + }; + + const now = Math.floor(Date.now() / 1000); + const jwtPayload: JWTPayload = { + ...payload, + iat: now, + exp: now + JWT_EXPIRES_IN + }; + + // Base64URL编码 + const encodedHeader = base64UrlEncode(JSON.stringify(header)); + const encodedPayload = base64UrlEncode(JSON.stringify(jwtPayload)); + + // 创建签名 + const signatureInput = `${encodedHeader}.${encodedPayload}`; + const signature = crypto + .createHmac('sha256', JWT_SECRET) + .update(signatureInput) + .digest('base64url'); + + return `${signatureInput}.${signature}`; +} + +/** + * 验证JWT Token + */ +export function verifyJWT(token: string): JWTPayload | null { + try { + const parts = token.split('.'); + if (parts.length !== 3) { + return null; + } + + const [encodedHeader, encodedPayload, signature] = parts; + + // 验证签名 + const signatureInput = `${encodedHeader}.${encodedPayload}`; + const expectedSignature = crypto + .createHmac('sha256', JWT_SECRET) + .update(signatureInput) + .digest('base64url'); + + if (signature !== expectedSignature) { + return null; + } + + // 解析payload + const payload = JSON.parse(base64UrlDecode(encodedPayload)) as JWTPayload; + + // 检查过期时间 + if (payload.exp && payload.exp < Math.floor(Date.now() / 1000)) { + return null; + } + + return payload; + } catch (error) { + console.error('JWT验证错误:', error); + return null; + } +} + +/** + * 从Authorization头中提取Token + */ +export function extractTokenFromHeader(authHeader: string | null): string | null { + if (!authHeader) { + return null; + } + + // 支持 "Bearer token" 格式 + if (authHeader.startsWith('Bearer ')) { + return authHeader.substring(7); + } + + // 直接返回token + return authHeader; +} + +/** + * Base64URL编码 + */ +function base64UrlEncode(str: string): string { + return Buffer.from(str) + .toString('base64') + .replace(/\+/g, '-') + .replace(/\//g, '_') + .replace(/=/g, ''); +} + +/** + * Base64URL解码 + */ +function base64UrlDecode(str: string): string { + // 添加填充字符 + str += '='.repeat((4 - str.length % 4) % 4); + return Buffer.from(str.replace(/\-/g, '+').replace(/_/g, '/'), 'base64').toString(); +} \ No newline at end of file diff --git a/src/pages/api/auth/login.ts b/src/pages/api/auth/login.ts index 544943b..777536a 100644 --- a/src/pages/api/auth/login.ts +++ b/src/pages/api/auth/login.ts @@ -1,5 +1,6 @@ import type { APIRoute } from 'astro'; import { authenticateUser } from '@/lib/database'; +import { createJWT } from '@/lib/jwt'; export const POST: APIRoute = async ({ request }) => { try { @@ -32,13 +33,21 @@ export const POST: APIRoute = async ({ request }) => { }); } + // 创建JWT token + const token = createJWT({ + userId: user.id, + username: user.username + }); + return new Response(JSON.stringify({ success: true, user: { id: user.id, username: user.username, created_at: user.created_at - } + }, + token, + expiresIn: 7 * 24 * 60 * 60 // 7天,单位:秒 }), { status: 200, headers: { 'Content-Type': 'application/json' }, diff --git a/src/pages/api/auth/music/delete.ts b/src/pages/api/auth/music/delete.ts index 670f4b6..61e676c 100644 --- a/src/pages/api/auth/music/delete.ts +++ b/src/pages/api/auth/music/delete.ts @@ -1,44 +1,50 @@ import type { APIRoute } from 'astro'; import { deleteMusic } from '@/lib/database'; -import { authenticateUser } from '@/lib/database'; +import { authenticateJWTRequest, parseRequestBody, handleApiError } from '@/lib/jwt-auth-middleware'; export const POST: APIRoute = async ({ request }) => { + // 首先进行JWT认证 + const authResult = await authenticateJWTRequest(request); + if (!authResult.success) { + return new Response(JSON.stringify({ error: authResult.error!.message }), { + status: authResult.error!.status, + headers: { 'Content-Type': 'application/json' } + }); + } + + // 解析请求体 + const bodyResult = await parseRequestBody(request, ['musicId']); + if (!bodyResult.success) { + return new Response(JSON.stringify({ error: bodyResult.error!.message }), { + status: bodyResult.error!.status, + headers: { 'Content-Type': 'application/json' } + }); + } + try { - const body = await request.text(); + const { user } = authResult; + const { data } = bodyResult; + const { musicId } = data; - if (!body.trim()) { - return new Response(JSON.stringify({ error: '请求体不能为空' }), { + // 验证音乐ID + if (!musicId || (typeof musicId !== 'string' && typeof musicId !== 'number')) { + return new Response(JSON.stringify({ + error: '音乐ID不能为空且必须是有效的标识符' + }), { 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' }, + headers: { 'Content-Type': 'application/json' } }); } // 删除音乐记录 - const success = deleteMusic(musicId, user.id); + const success = deleteMusic(musicId.toString(), user!.id); if (!success) { - return new Response(JSON.stringify({ error: '音乐不存在或无权限删除' }), { + return new Response(JSON.stringify({ + error: '音乐不存在或无权限删除' + }), { status: 404, - headers: { 'Content-Type': 'application/json' }, + headers: { 'Content-Type': 'application/json' } }); } @@ -47,23 +53,10 @@ export const POST: APIRoute = async ({ request }) => { message: '音乐删除成功' }), { status: 200, - headers: { 'Content-Type': 'application/json' }, + 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' }, - }); + return handleApiError(error, '删除音乐'); } }; \ No newline at end of file diff --git a/src/pages/api/auth/music/list.ts b/src/pages/api/auth/music/list.ts index 61217af..8a842d8 100644 --- a/src/pages/api/auth/music/list.ts +++ b/src/pages/api/auth/music/list.ts @@ -1,75 +1,63 @@ import type { APIRoute } from 'astro'; import { getUserMusic } from '@/lib/database'; -import { authenticateUser } from '@/lib/database'; +import { authenticateJWTRequest, handleApiError } from '@/lib/jwt-auth-middleware'; export const POST: APIRoute = async ({ request }) => { + // 使用JWT认证中间件 + const authResult = await authenticateJWTRequest(request); + if (!authResult.success) { + return new Response(JSON.stringify({ error: authResult.error!.message }), { + status: authResult.error!.status, + headers: { 'Content-Type': 'application/json' } + }); + } + 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 { user } = authResult; // 获取用户音乐列表 - const musicList = getUserMusic(user.id); + 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 - })); + // 解析JSON字段并格式化数据 + const formattedMusicList = musicList.map(music => { + try { + return { + 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 + }; + } catch (parseError) { + console.error(`解析音乐数据失败 (ID: ${music.id}):`, parseError); + // 返回安全的默认值 + return { + id: music.id, + name: music.name, + sounds: [], + volume: {}, + speed: {}, + rate: {}, + 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' }, + 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' }, - }); + return handleApiError(error, '获取音乐列表'); } }; \ No newline at end of file diff --git a/src/pages/api/auth/music/rename.ts b/src/pages/api/auth/music/rename.ts index 264d731..ba1f973 100644 --- a/src/pages/api/auth/music/rename.ts +++ b/src/pages/api/auth/music/rename.ts @@ -1,44 +1,69 @@ import type { APIRoute } from 'astro'; import { updateMusicName } from '@/lib/database'; -import { authenticateUser } from '@/lib/database'; +import { authenticateJWTRequest, parseRequestBody, handleApiError } from '@/lib/jwt-auth-middleware'; export const POST: APIRoute = async ({ request }) => { + // 首先进行JWT认证 + const authResult = await authenticateJWTRequest(request); + if (!authResult.success) { + return new Response(JSON.stringify({ error: authResult.error!.message }), { + status: authResult.error!.status, + headers: { 'Content-Type': 'application/json' } + }); + } + + // 解析请求体 + const bodyResult = await parseRequestBody(request, ['musicId', 'name']); + if (!bodyResult.success) { + return new Response(JSON.stringify({ error: bodyResult.error!.message }), { + status: bodyResult.error!.status, + headers: { 'Content-Type': 'application/json' } + }); + } + 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); + const { user } = authResult; + const { data } = bodyResult; + const { musicId, name } = data; // 验证输入 - if (!musicId || !name || !username || !password) { - return new Response(JSON.stringify({ error: '音乐ID、新名称和用户信息不能为空' }), { + if (!musicId || (typeof musicId !== 'string' && typeof musicId !== 'number')) { + return new Response(JSON.stringify({ + error: '音乐ID不能为空且必须是有效的标识符' + }), { status: 400, - headers: { 'Content-Type': 'application/json' }, + 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' }, + if (!name || typeof name !== 'string' || name.trim().length === 0) { + return new Response(JSON.stringify({ + error: '音乐名称不能为空且必须是有效的字符串' + }), { + status: 400, + headers: { 'Content-Type': 'application/json' } + }); + } + + // 验证名称长度 + if (name.trim().length > 100) { + return new Response(JSON.stringify({ + error: '音乐名称长度不能超过100个字符' + }), { + status: 400, + headers: { 'Content-Type': 'application/json' } }); } // 更新音乐名称 - const success = updateMusicName(musicId, name, user.id); + const success = updateMusicName(musicId.toString(), name.trim(), user!.id); if (!success) { - return new Response(JSON.stringify({ error: '音乐不存在或无权限修改' }), { + return new Response(JSON.stringify({ + error: '音乐不存在或无权限修改' + }), { status: 404, - headers: { 'Content-Type': 'application/json' }, + headers: { 'Content-Type': 'application/json' } }); } @@ -47,23 +72,10 @@ export const POST: APIRoute = async ({ request }) => { message: '音乐名称更新成功' }), { status: 200, - headers: { 'Content-Type': 'application/json' }, + 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' }, - }); + return handleApiError(error, '重命名音乐'); } }; \ No newline at end of file diff --git a/src/pages/api/auth/music/save.ts b/src/pages/api/auth/music/save.ts index 052c5f7..8b9d7cd 100644 --- a/src/pages/api/auth/music/save.ts +++ b/src/pages/api/auth/music/save.ts @@ -1,40 +1,44 @@ import type { APIRoute } from 'astro'; import { createMusic } from '@/lib/database'; -import { authenticateUser } from '@/lib/database'; +import { authenticateJWTRequest, parseRequestBody, handleApiError } from '@/lib/jwt-auth-middleware'; export const POST: APIRoute = async ({ request }) => { + // 首先进行JWT认证 + const authResult = await authenticateJWTRequest(request); + if (!authResult.success) { + return new Response(JSON.stringify({ error: authResult.error!.message }), { + status: authResult.error!.status, + headers: { 'Content-Type': 'application/json' } + }); + } + + // 解析请求体 + const bodyResult = await parseRequestBody(request, ['name', 'sounds']); + if (!bodyResult.success) { + return new Response(JSON.stringify({ error: bodyResult.error!.message }), { + status: bodyResult.error!.status, + headers: { 'Content-Type': 'application/json' } + }); + } + 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); + const { user } = authResult; + const { data } = bodyResult; + const { name, sounds, volume, speed, rate, random_effects } = data; // 验证输入 - if (!name || !sounds || !username || !password) { - return new Response(JSON.stringify({ error: '音乐名称、声音配置和用户信息不能为空' }), { + if (!name || !sounds || !Array.isArray(sounds)) { + 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' }, + headers: { 'Content-Type': 'application/json' } }); } // 创建音乐记录 const music = await createMusic({ - user_id: user.id, + user_id: user!.id, name, sounds, volume: volume || {}, @@ -45,6 +49,7 @@ export const POST: APIRoute = async ({ request }) => { return new Response(JSON.stringify({ success: true, + message: '音乐保存成功', music: { id: music.id, name: music.name, @@ -52,23 +57,10 @@ export const POST: APIRoute = async ({ request }) => { } }), { status: 201, - headers: { 'Content-Type': 'application/json' }, + 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' }, - }); + return handleApiError(error, '保存音乐'); } }; \ No newline at end of file diff --git a/src/pages/api/auth/register.ts b/src/pages/api/auth/register.ts index 45d64d7..5b4fa61 100644 --- a/src/pages/api/auth/register.ts +++ b/src/pages/api/auth/register.ts @@ -1,5 +1,6 @@ import type { APIRoute } from 'astro'; import { createUser } from '@/lib/database'; +import { createJWT } from '@/lib/jwt'; export const POST: APIRoute = async ({ request }) => { try { @@ -39,13 +40,21 @@ export const POST: APIRoute = async ({ request }) => { // 创建用户 const user = await createUser({ username, password }); + // 创建JWT token + const token = createJWT({ + userId: user.id, + username: user.username + }); + return new Response(JSON.stringify({ success: true, user: { id: user.id, username: user.username, created_at: user.created_at - } + }, + token, + expiresIn: 7 * 24 * 60 * 60 // 7天,单位:秒 }), { status: 201, headers: { 'Content-Type': 'application/json' }, diff --git a/src/stores/auth.ts b/src/stores/auth.ts index feb1024..112ab54 100644 --- a/src/stores/auth.ts +++ b/src/stores/auth.ts @@ -12,7 +12,7 @@ interface AuthState { isAuthenticated: boolean; isLoading: boolean; error: string | null; - sessionPassword: string | null; // 仅当前会话使用的密码,不持久化 + token: string | null; // JWT token } interface AuthStore extends AuthState { @@ -23,6 +23,8 @@ interface AuthStore extends AuthState { clearError: () => void; setLoading: (loading: boolean) => void; checkAuth: () => Promise; + getToken: () => string | null; + setToken: (token: string) => void; } /** @@ -57,7 +59,7 @@ export const useAuthStore = create()( isAuthenticated: false, isLoading: false, error: null, - sessionPassword: null, + token: null, // Actions login: async (userData) => { @@ -66,20 +68,14 @@ export const useAuthStore = create()( try { const result = await apiCall('/api/auth/login', userData); const user = result.user; + const token = result.token; set({ user, isAuthenticated: true, isLoading: false, error: null, - }); - - set({ - user, - isAuthenticated: true, - isLoading: false, - error: null, - sessionPassword: userData.password, // 保存密码用于当前会话的API调用 + token, }); console.log('✅ 用户登录成功:', user.username); @@ -90,7 +86,7 @@ export const useAuthStore = create()( isAuthenticated: false, isLoading: false, error: errorMessage, - sessionPassword: null, + token: null, }); console.error('❌ 登录失败:', error); throw error; @@ -103,20 +99,14 @@ export const useAuthStore = create()( try { const result = await apiCall('/api/auth/register', userData); const user = result.user; + const token = result.token; set({ user, isAuthenticated: true, isLoading: false, error: null, - }); - - set({ - user, - isAuthenticated: true, - isLoading: false, - error: null, - sessionPassword: userData.password, // 保存密码用于当前会话的API调用 + token, }); console.log('✅ 用户注册成功:', user.username); @@ -127,7 +117,7 @@ export const useAuthStore = create()( isAuthenticated: false, isLoading: false, error: errorMessage, - sessionPassword: null, + token: null, }); console.error('❌ 注册失败:', error); throw error; @@ -140,7 +130,7 @@ export const useAuthStore = create()( isAuthenticated: false, isLoading: false, error: null, - sessionPassword: null, // 清除会话密码 + token: null, }); console.log('✅ 用户已登出'); }, @@ -154,37 +144,33 @@ export const useAuthStore = create()( }, checkAuth: async () => { - const { user, isAuthenticated } = get(); + const { user, isAuthenticated, token } = get(); // 如果已经有用户信息且已认证,则直接返回 - if (user && isAuthenticated) { + if (user && isAuthenticated && token) { return; } - // 这里可以添加token验证或会话验证逻辑 - // 目前简单检查本地存储的用户信息 - set({ isLoading: true }); - - try { - // 如果有用户信息但未认证,可以尝试验证 - if (user && !isAuthenticated) { - set({ - isAuthenticated: true, - isLoading: false, - error: null, - }); - } else { - set({ isLoading: false }); - } - } catch (error) { - console.error('❌ 认证检查失败:', error); + // zustand persist会自动从localStorage恢复token + // 如果有token但没有用户信息,说明token可能无效 + if (token && !user) { + console.warn('发现token但缺少用户信息,可能需要重新登录'); set({ - user: null, - isAuthenticated: false, - isLoading: false, - error: '认证检查失败', + token: null, + isAuthenticated: false }); } + + set({ isLoading: false }); + }, + + getToken: () => { + const { token } = get(); + return token; + }, + + setToken: (newToken: string) => { + set({ token: newToken }); }, }), { @@ -192,7 +178,7 @@ export const useAuthStore = create()( partialize: (state) => ({ user: state.user, isAuthenticated: state.isAuthenticated, - // 不包含 sessionPassword,仅存储在内存中 + token: state.token, // 现在也保存token }), } ) diff --git a/test-jwt.js b/test-jwt.js new file mode 100644 index 0000000..ebf9ff4 --- /dev/null +++ b/test-jwt.js @@ -0,0 +1,71 @@ +import crypto from 'crypto'; + +// JWT配置 (与src/lib/jwt.ts保持一致) +const JWT_SECRET = 'your-secret-key-change-in-production'; +const JWT_ALGORITHM = 'HS256'; +const JWT_EXPIRES_IN = 7 * 24 * 60 * 60; // 7天 + +function base64UrlEncode(data) { + return Buffer.from(data) + .toString('base64') + .replace(/\+/g, '-') + .replace(/\//g, '_') + .replace(/=/g, ''); +} + +function createJWT(payload) { + const header = { alg: JWT_ALGORITHM, typ: 'JWT' }; + const now = Math.floor(Date.now() / 1000); + const jwtPayload = { ...payload, iat: now, exp: now + JWT_EXPIRES_IN }; + + const encodedHeader = base64UrlEncode(JSON.stringify(header)); + const encodedPayload = base64UrlEncode(JSON.stringify(jwtPayload)); + const signatureInput = `${encodedHeader}.${encodedPayload}`; + + const signature = crypto + .createHmac('sha256', JWT_SECRET) + .update(signatureInput) + .digest('base64url'); + + return `${signatureInput}.${signature}`; +} + +// 创建测试用的JWT token (用户ID: 1, username: test123) +const testToken = createJWT({ + userId: 1, + username: 'test123' +}); + +console.log('测试JWT Token:', testToken); + +// 测试API调用 +async function testMusicAPI() { + try { + const response = await fetch('http://localhost:4323/api/auth/music/list', { + method: 'POST', + headers: { + 'Content-Type': 'application/json', + 'Authorization': `Bearer ${testToken}` + }, + body: JSON.stringify({}) + }); + + console.log('API响应状态:', response.status); + console.log('API响应头:', Object.fromEntries(response.headers.entries())); + + const text = await response.text(); + console.log('API响应内容:', text); + + try { + const data = JSON.parse(text); + console.log('解析后的数据:', data); + } catch (e) { + console.log('响应不是有效的JSON'); + } + + } catch (error) { + console.error('API调用失败:', error); + } +} + +testMusicAPI(); \ No newline at end of file diff --git a/users.db b/users.db new file mode 100644 index 0000000..e69de29