mirror of
https://github.com/remvze/moodist.git
synced 2025-12-18 17:34:17 +00:00
feat: 重构音乐列表UI与JWT认证完整实现 v2.7.0
## 🎯 核心功能重构 ### 音乐列表显示优化 - **自动展示**: 登录用户页面打开时自动显示音乐列表,无需手动展开 - **权限控制**: 未登录用户完全隐藏"我的音乐"部分 - **独立展开**: 每个音乐项配备独立的展开/收起按钮 - **渐进展示**: 点击展开按钮显示音乐收录的声音详情 ### JWT认证系统完整实现 - **安全升级**: 完全替代密码传输,实现JWT令牌认证 - **自动管理**: 登录时自动生成和存储JWT令牌 - **API集成**: 所有音乐相关API统一使用JWT认证 - **容错机制**: 多层级token获取策略确保认证稳定性 ## 🔧 技术架构升级 ### 新增核心模块 - `src/lib/jwt.ts` - JWT令牌创建与验证核心 - `src/lib/jwt-auth-middleware.ts` - JWT认证中间件 - `src/lib/api-client.ts` - 自动JWT令牌注入的API客户端 - `src/hooks/useNotification.ts` - 统一通知系统 ### 组件化重构 - `src/components/buttons/save-music/` - 音乐保存按钮组件 - `src/components/buttons/delete-music/` - 音乐删除按钮组件 - `src/components/notification/` - 通知组件系统 ### API安全强化 - 所有认证相关API集成JWT中间件 - 用户注册/登录自动返回JWT令牌 - 音乐CRUD操作统一JWT认证验证 ## 🎨 用户体验优化 ### 交互流程简化 - 登录即见:音乐列表自动展示,减少用户操作步骤 - 按需展开:声音详情按需显示,避免信息过载 - 状态持久:JWT令牌自动管理,无需重复登录 ### 视觉层次优化 - 音乐名称与展开按钮并排布局,提升操作便利性 - 声音列表折叠显示,保持界面整洁 - 统一通知样式,确保视觉一致性 ## 🛡️ 安全性提升 - **零密码传输**: API请求完全移除明文密码传输 - **令牌过期**: JWT令牌7天自动过期机制 - **状态隔离**: 认证状态与业务状态完全分离 版本: v2.7.0 技术栈: React + TypeScript + Astro + SQLite + JWT
This commit is contained in:
parent
e01092d97e
commit
010fb9674b
27 changed files with 2454 additions and 321 deletions
BIN
data/users.db
BIN
data/users.db
Binary file not shown.
|
|
@ -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() {
|
|||
<div id="app" />
|
||||
<Buttons />
|
||||
<SelectedSoundsDisplay />
|
||||
<SavedMusicList />
|
||||
<Categories categories={allCategories} />
|
||||
</Container>
|
||||
|
||||
|
|
|
|||
155
src/components/buttons/delete-music/delete-music.module.css
Normal file
155
src/components/buttons/delete-music/delete-music.module.css
Normal file
|
|
@ -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;
|
||||
}
|
||||
186
src/components/buttons/delete-music/delete-music.tsx
Normal file
186
src/components/buttons/delete-music/delete-music.tsx
Normal file
|
|
@ -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<SavedMusic[]>([]);
|
||||
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 (
|
||||
<div className={styles.deleteDropdownContainer}>
|
||||
<button
|
||||
className={cn(styles.deleteButton, !isAuthenticated && styles.disabled)}
|
||||
onClick={handleToggleDropdown}
|
||||
disabled={!isAuthenticated}
|
||||
title={isAuthenticated ? '删除保存的音乐' : '请先登录后再删除'}
|
||||
>
|
||||
<FaTrash />
|
||||
</button>
|
||||
|
||||
{/* 删除下拉菜单 */}
|
||||
{showDeleteDropdown && (
|
||||
<div className={styles.deleteDropdown}>
|
||||
<div className={styles.dropdownHeader}>
|
||||
<h4>删除音乐</h4>
|
||||
<button
|
||||
className={styles.closeButton}
|
||||
onClick={() => setShowDeleteDropdown(false)}
|
||||
>
|
||||
×
|
||||
</button>
|
||||
</div>
|
||||
|
||||
{isLoadingMusic ? (
|
||||
<div className={styles.loading}>加载中...</div>
|
||||
) : savedMusicList.length === 0 ? (
|
||||
<div className={styles.empty}>没有可删除的音乐</div>
|
||||
) : (
|
||||
<div className={styles.musicList}>
|
||||
{savedMusicList.map((music) => (
|
||||
<div key={music.id} className={styles.musicItem}>
|
||||
<span className={styles.musicName}>{music.name}</span>
|
||||
<button
|
||||
onClick={() => deleteMusic(music.id.toString(), music.name)}
|
||||
className={styles.deleteItemButton}
|
||||
title={`删除 ${music.name}`}
|
||||
disabled={isDeleting}
|
||||
>
|
||||
{isDeleting ? '删除中...' : '删除'}
|
||||
</button>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
48
src/components/buttons/save-music/save-music.module.css
Normal file
48
src/components/buttons/save-music/save-music.module.css
Normal file
|
|
@ -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 中 */
|
||||
156
src/components/buttons/save-music/save-music.tsx
Normal file
156
src/components/buttons/save-music/save-music.tsx
Normal file
|
|
@ -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<string, number> = {};
|
||||
const speed: Record<string, number> = {};
|
||||
const rate: Record<string, number> = {};
|
||||
const random_effects: Record<string, boolean> = {};
|
||||
|
||||
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 (
|
||||
<>
|
||||
<button
|
||||
className={cn(styles.saveButton, noSelected && styles.disabled, isSaving && styles.saving)}
|
||||
onClick={handleSave}
|
||||
disabled={isSaving || noSelected}
|
||||
title={isAuthenticated ? '保存当前音乐配置' : '请先登录后再保存'}
|
||||
>
|
||||
<FaSave />
|
||||
</button>
|
||||
|
||||
{/* 登录提示 */}
|
||||
{showLoginPrompt && (
|
||||
<div className={soundsStyles.loginPrompt}>
|
||||
<p>请先登录后再保存音乐</p>
|
||||
<button
|
||||
onClick={() => {
|
||||
setShowLoginPrompt(false);
|
||||
// 触发LanguageSwitcher的登录表单
|
||||
const event = new CustomEvent('showLoginForm', { bubbles: true });
|
||||
document.dispatchEvent(event);
|
||||
}}
|
||||
>
|
||||
去登录
|
||||
</button>
|
||||
<button onClick={() => setShowLoginPrompt(false)}>
|
||||
取消
|
||||
</button>
|
||||
</div>
|
||||
)}
|
||||
|
||||
|
||||
{/* 通用通知组件 */}
|
||||
<Notification
|
||||
show={notificationState.showNotification}
|
||||
message={notificationState.notificationMessage}
|
||||
type={notificationState.notificationType}
|
||||
onClose={notificationState.hideNotification}
|
||||
/>
|
||||
</>
|
||||
);
|
||||
}
|
||||
90
src/components/notification/notification.module.css
Normal file
90
src/components/notification/notification.module.css
Normal file
|
|
@ -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;
|
||||
}
|
||||
}
|
||||
32
src/components/notification/notification.tsx
Normal file
32
src/components/notification/notification.tsx
Normal file
|
|
@ -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 (
|
||||
<AnimatePresence>
|
||||
{show && (
|
||||
<div className={`${styles.notification} ${styles[type]}`}>
|
||||
<div className={styles.notificationContent}>
|
||||
<span className={styles.notificationMessage}>
|
||||
{message}
|
||||
</span>
|
||||
<button
|
||||
className={styles.notificationClose}
|
||||
onClick={onClose}
|
||||
aria-label="关闭通知"
|
||||
>
|
||||
×
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</AnimatePresence>
|
||||
);
|
||||
}
|
||||
|
|
@ -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<SavedMusic[]>([]);
|
||||
const [loading, setLoading] = useState(false);
|
||||
const [editingId, setEditingId] = useState<string | null>(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) {
|
||||
|
|
|
|||
|
|
@ -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<string, number>;
|
||||
speed: Record<string, number>;
|
||||
rate: Record<string, number>;
|
||||
random_effects: Record<string, boolean>;
|
||||
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<SavedMusic[]>([]);
|
||||
const [isLoadingMusic, setIsLoadingMusic] = useState(false);
|
||||
const [editingId, setEditingId] = useState<string | null>(null);
|
||||
const [editingName, setEditingName] = useState('');
|
||||
const [showMusicDropdown, setShowMusicDropdown] = useState(true); // 默认展开
|
||||
const [expandedMusic, setExpandedMusic] = useState<Set<number>>(new Set()); // 跟踪展开的音乐项
|
||||
const [error, setError] = useState<string | null>(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 (
|
||||
<div className={styles.soundsContainer}>
|
||||
{/* 音乐名称配置区域 */}
|
||||
{selectedSounds.length > 0 && (
|
||||
<div className={styles.musicNameConfig}>
|
||||
<input
|
||||
type="text"
|
||||
value={musicName}
|
||||
onChange={(e) => setMusicName(e.target.value)}
|
||||
placeholder="音乐名称"
|
||||
className={styles.musicNameInput}
|
||||
maxLength={50}
|
||||
/>
|
||||
<SaveMusicButton />
|
||||
<DeleteMusicButton />
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* 选中的声音展示 */}
|
||||
<div className={styles.sounds}>
|
||||
<AnimatePresence initial={false}>
|
||||
{selectedSounds.map((sound) => (
|
||||
|
|
@ -137,47 +411,170 @@ export function SelectedSoundsDisplay() {
|
|||
</AnimatePresence>
|
||||
</div>
|
||||
|
||||
{/* 保存按钮区域 */}
|
||||
<div className={styles.saveSection}>
|
||||
<button
|
||||
className={`${styles.saveButton} ${isSaving ? styles.saving : ''}`}
|
||||
onClick={saveMusic}
|
||||
disabled={isSaving || selectedSounds.length === 0}
|
||||
title={isAuthenticated ? '保存当前音乐配置' : '请先登录后再保存'}
|
||||
>
|
||||
<FaSave />
|
||||
<span>
|
||||
{isSaving ? '保存中...' : '保存音乐'}
|
||||
</span>
|
||||
</button>
|
||||
|
||||
{/* 保存成功提示 */}
|
||||
{showSaveSuccess && (
|
||||
<div className={styles.saveSuccess}>
|
||||
✓ 音乐保存成功!
|
||||
{/* 音乐列表区域 - 只有登录用户才显示 */}
|
||||
{isAuthenticated && (
|
||||
<div className={styles.musicSection}>
|
||||
<div className={styles.musicHeader}>
|
||||
<h4 className={styles.musicTitle}>
|
||||
<FaCog className={styles.musicIcon} />
|
||||
我的音乐
|
||||
</h4>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* 登录提示 */}
|
||||
{showLoginPrompt && (
|
||||
<div className={styles.loginPrompt}>
|
||||
<p>请先登录后再保存音乐</p>
|
||||
<button
|
||||
onClick={() => {
|
||||
setShowLoginPrompt(false);
|
||||
// 触发LanguageSwitcher的登录表单
|
||||
const event = new CustomEvent('showLoginForm', { bubbles: true });
|
||||
document.dispatchEvent(event);
|
||||
}}
|
||||
>
|
||||
去登录
|
||||
</button>
|
||||
<button onClick={() => setShowLoginPrompt(false)}>
|
||||
取消
|
||||
</button>
|
||||
{/* 错误提示 */}
|
||||
{error && (
|
||||
<div className={styles.error}>
|
||||
{error}
|
||||
<button onClick={() => setError(null)} className={styles.errorClose}>×</button>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* 保存成功提示 */}
|
||||
{showSaveSuccess && (
|
||||
<div className={styles.saveSuccess}>
|
||||
<p>✓ 音乐保存成功!</p>
|
||||
<button onClick={() => setShowSaveSuccess(false)}>
|
||||
确定
|
||||
</button>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* 音乐列表 - 自动显示 */}
|
||||
<div className={styles.musicList}>
|
||||
{console.log('🎵 渲染音乐列表:', { isLoadingMusic, listLength: savedMusicList.length })}
|
||||
{isLoadingMusic ? (
|
||||
<div className={styles.loading}>加载中...</div>
|
||||
) : savedMusicList.length === 0 ? (
|
||||
<div className={styles.empty}>
|
||||
<FaMusic className={styles.emptyIcon} />
|
||||
<p>还没有保存的音乐</p>
|
||||
<p className={styles.emptyHint}>选择声音并点击保存按钮来创建你的第一首音乐</p>
|
||||
</div>
|
||||
) : (
|
||||
<AnimatePresence initial={false}>
|
||||
{savedMusicList.map((music) => (
|
||||
<div key={music.id} className={styles.musicItem}>
|
||||
{editingId === music.id.toString() ? (
|
||||
<div className={styles.editForm}>
|
||||
<input
|
||||
type="text"
|
||||
value={editingName}
|
||||
onChange={(e) => setEditingName(e.target.value)}
|
||||
className={styles.editInput}
|
||||
placeholder="输入音乐名称"
|
||||
maxLength={50}
|
||||
/>
|
||||
<div className={styles.editButtons}>
|
||||
<button
|
||||
onClick={() => {
|
||||
if (editingName.trim()) {
|
||||
renameMusic(music.id.toString(), editingName.trim());
|
||||
}
|
||||
}}
|
||||
className={`${styles.editButton} ${styles.saveButton}`}
|
||||
title="保存"
|
||||
>
|
||||
✓
|
||||
</button>
|
||||
<button
|
||||
onClick={() => {
|
||||
setEditingId(null);
|
||||
setEditingName('');
|
||||
}}
|
||||
className={`${styles.editButton} ${styles.cancelButton}`}
|
||||
title="取消"
|
||||
>
|
||||
×
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
) : (
|
||||
<div className={styles.musicContent}>
|
||||
<button
|
||||
onClick={() => playSavedMusic(music)}
|
||||
className={styles.playButton}
|
||||
title="播放这首音乐"
|
||||
>
|
||||
<FaPlay />
|
||||
</button>
|
||||
<div className={styles.musicInfo}>
|
||||
<div className={styles.musicNameRow}>
|
||||
<span
|
||||
className={styles.musicName}
|
||||
onClick={() => {
|
||||
setEditingId(music.id.toString());
|
||||
setEditingName(music.name);
|
||||
}}
|
||||
title="点击编辑名称"
|
||||
>
|
||||
{music.name}
|
||||
</span>
|
||||
<button
|
||||
onClick={() => toggleMusicExpansion(music.id)}
|
||||
className={styles.expandButton}
|
||||
title="展开/收起声音详情"
|
||||
>
|
||||
{expandedMusic.has(music.id) ? '收起 ▲' : '展开 ▼'}
|
||||
</button>
|
||||
</div>
|
||||
{/* 展开时显示收录的声音名字 */}
|
||||
{expandedMusic.has(music.id) && (
|
||||
<div className={styles.soundNames}>
|
||||
{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 ? (
|
||||
<span key={soundId} className={styles.soundName}>
|
||||
{sound.label}{index < music.sounds.length - 1 ? ', ' : ''}
|
||||
</span>
|
||||
) : null;
|
||||
})
|
||||
) : (
|
||||
<span className={styles.noSounds}>暂无声音</span>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
<button
|
||||
onClick={() => deleteMusic(music.id.toString())}
|
||||
className={styles.deleteButton}
|
||||
title="删除"
|
||||
>
|
||||
<FaTrash />
|
||||
</button>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
))}
|
||||
</AnimatePresence>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* 登录提示 */}
|
||||
{showLoginPrompt && (
|
||||
<div className={styles.loginPrompt}>
|
||||
<p>请先登录后再保存音乐</p>
|
||||
<button
|
||||
onClick={() => {
|
||||
setShowLoginPrompt(false);
|
||||
// 触发LanguageSwitcher的登录表单
|
||||
const event = new CustomEvent('showLoginForm', { bubbles: true });
|
||||
document.dispatchEvent(event);
|
||||
}}
|
||||
>
|
||||
去登录
|
||||
</button>
|
||||
<button onClick={() => setShowLoginPrompt(false)}>
|
||||
取消
|
||||
</button>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
|
@ -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 {
|
||||
|
|
|
|||
|
|
@ -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 {
|
||||
|
|
|
|||
|
|
@ -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 {
|
||||
|
|
|
|||
38
src/hooks/useNotification.ts
Normal file
38
src/hooks/useNotification.ts
Normal file
|
|
@ -0,0 +1,38 @@
|
|||
import { useState } from 'react';
|
||||
|
||||
interface NotificationState {
|
||||
showNotification: boolean;
|
||||
notificationMessage: string;
|
||||
notificationType: 'success' | 'error';
|
||||
}
|
||||
|
||||
export function useNotification() {
|
||||
const [state, setState] = useState<NotificationState>({
|
||||
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
|
||||
};
|
||||
}
|
||||
135
src/lib/api-client.ts
Normal file
135
src/lib/api-client.ts
Normal file
|
|
@ -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<Response>
|
||||
*/
|
||||
static async fetch(url: string, options: RequestInit = {}): Promise<Response> {
|
||||
// 获取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<Response>
|
||||
*/
|
||||
static async post(url: string, data?: any): Promise<Response> {
|
||||
return this.fetch(url, {
|
||||
method: 'POST',
|
||||
body: data ? JSON.stringify(data) : undefined,
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* 发起GET请求
|
||||
* @param url - API URL
|
||||
* @returns Promise<Response>
|
||||
*/
|
||||
static async get(url: string): Promise<Response> {
|
||||
return this.fetch(url, {
|
||||
method: 'GET',
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* 发起PUT请求
|
||||
* @param url - API URL
|
||||
* @param data - 请求数据
|
||||
* @returns Promise<Response>
|
||||
*/
|
||||
static async put(url: string, data?: any): Promise<Response> {
|
||||
return this.fetch(url, {
|
||||
method: 'PUT',
|
||||
body: data ? JSON.stringify(data) : undefined,
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* 发起DELETE请求
|
||||
* @param url - API URL
|
||||
* @returns Promise<Response>
|
||||
*/
|
||||
static async delete(url: string): Promise<Response> {
|
||||
return this.fetch(url, {
|
||||
method: 'DELETE',
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 简化的API调用函数
|
||||
* @param url - API URL
|
||||
* @param data - 请求数据
|
||||
* @param method - HTTP方法
|
||||
* @returns Promise<any>
|
||||
*/
|
||||
export async function apiCall(url: string, data?: any, method: 'POST' | 'GET' | 'PUT' | 'DELETE' = 'POST'): Promise<any> {
|
||||
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;
|
||||
}
|
||||
166
src/lib/auth-middleware.ts
Normal file
166
src/lib/auth-middleware.ts
Normal file
|
|
@ -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<AuthResult> {
|
||||
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);
|
||||
}
|
||||
195
src/lib/jwt-auth-middleware.ts
Normal file
195
src/lib/jwt-auth-middleware.ts
Normal file
|
|
@ -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<JWTAuthResult> {
|
||||
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);
|
||||
}
|
||||
118
src/lib/jwt.ts
Normal file
118
src/lib/jwt.ts
Normal file
|
|
@ -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<JWTPayload, 'iat' | 'exp'>): 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();
|
||||
}
|
||||
|
|
@ -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' },
|
||||
|
|
|
|||
|
|
@ -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, '删除音乐');
|
||||
}
|
||||
};
|
||||
|
|
@ -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, '获取音乐列表');
|
||||
}
|
||||
};
|
||||
|
|
@ -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, '重命名音乐');
|
||||
}
|
||||
};
|
||||
|
|
@ -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, '保存音乐');
|
||||
}
|
||||
};
|
||||
|
|
@ -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' },
|
||||
|
|
|
|||
|
|
@ -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<void>;
|
||||
getToken: () => string | null;
|
||||
setToken: (token: string) => void;
|
||||
}
|
||||
|
||||
/**
|
||||
|
|
@ -57,7 +59,7 @@ export const useAuthStore = create<AuthStore>()(
|
|||
isAuthenticated: false,
|
||||
isLoading: false,
|
||||
error: null,
|
||||
sessionPassword: null,
|
||||
token: null,
|
||||
|
||||
// Actions
|
||||
login: async (userData) => {
|
||||
|
|
@ -66,20 +68,14 @@ export const useAuthStore = create<AuthStore>()(
|
|||
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<AuthStore>()(
|
|||
isAuthenticated: false,
|
||||
isLoading: false,
|
||||
error: errorMessage,
|
||||
sessionPassword: null,
|
||||
token: null,
|
||||
});
|
||||
console.error('❌ 登录失败:', error);
|
||||
throw error;
|
||||
|
|
@ -103,20 +99,14 @@ export const useAuthStore = create<AuthStore>()(
|
|||
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<AuthStore>()(
|
|||
isAuthenticated: false,
|
||||
isLoading: false,
|
||||
error: errorMessage,
|
||||
sessionPassword: null,
|
||||
token: null,
|
||||
});
|
||||
console.error('❌ 注册失败:', error);
|
||||
throw error;
|
||||
|
|
@ -140,7 +130,7 @@ export const useAuthStore = create<AuthStore>()(
|
|||
isAuthenticated: false,
|
||||
isLoading: false,
|
||||
error: null,
|
||||
sessionPassword: null, // 清除会话密码
|
||||
token: null,
|
||||
});
|
||||
console.log('✅ 用户已登出');
|
||||
},
|
||||
|
|
@ -154,37 +144,33 @@ export const useAuthStore = create<AuthStore>()(
|
|||
},
|
||||
|
||||
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<AuthStore>()(
|
|||
partialize: (state) => ({
|
||||
user: state.user,
|
||||
isAuthenticated: state.isAuthenticated,
|
||||
// 不包含 sessionPassword,仅存储在内存中
|
||||
token: state.token, // 现在也保存token
|
||||
}),
|
||||
}
|
||||
)
|
||||
|
|
|
|||
71
test-jwt.js
Normal file
71
test-jwt.js
Normal file
|
|
@ -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();
|
||||
0
users.db
Normal file
0
users.db
Normal file
Loading…
Add table
Reference in a new issue