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 { StoreConsumer } from '@/components/store-consumer';
|
||||||
import { Buttons } from '@/components/buttons';
|
import { Buttons } from '@/components/buttons';
|
||||||
import { SelectedSoundsDisplay } from '@/components/selected-sounds-display';
|
import { SelectedSoundsDisplay } from '@/components/selected-sounds-display';
|
||||||
import { SavedMusicList } from '@/components/saved-music-list';
|
|
||||||
import { Categories } from '@/components/categories';
|
import { Categories } from '@/components/categories';
|
||||||
import { SharedModal } from '@/components/modals/shared';
|
import { SharedModal } from '@/components/modals/shared';
|
||||||
import { Toolbar } from '@/components/toolbar';
|
import { Toolbar } from '@/components/toolbar';
|
||||||
|
|
@ -100,7 +99,6 @@ export function App() {
|
||||||
<div id="app" />
|
<div id="app" />
|
||||||
<Buttons />
|
<Buttons />
|
||||||
<SelectedSoundsDisplay />
|
<SelectedSoundsDisplay />
|
||||||
<SavedMusicList />
|
|
||||||
<Categories categories={allCategories} />
|
<Categories categories={allCategories} />
|
||||||
</Container>
|
</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 { useAuthStore } from '@/stores/auth';
|
||||||
import { useSoundStore } from '@/stores/sound';
|
import { useSoundStore } from '@/stores/sound';
|
||||||
import { useTranslation } from '@/hooks/useTranslation';
|
import { useTranslation } from '@/hooks/useTranslation';
|
||||||
|
import { ApiClient } from '@/lib/api-client';
|
||||||
|
|
||||||
import type { SavedMusic } from '@/lib/database';
|
import type { SavedMusic } from '@/lib/database';
|
||||||
|
|
||||||
|
|
@ -16,7 +17,7 @@ interface SavedMusicListProps {
|
||||||
|
|
||||||
export function SavedMusicList({ onMusicSelect }: SavedMusicListProps) {
|
export function SavedMusicList({ onMusicSelect }: SavedMusicListProps) {
|
||||||
const { t } = useTranslation();
|
const { t } = useTranslation();
|
||||||
const { isAuthenticated, user, sessionPassword } = useAuthStore();
|
const { isAuthenticated, user } = useAuthStore();
|
||||||
const [savedMusicList, setSavedMusicList] = useState<SavedMusic[]>([]);
|
const [savedMusicList, setSavedMusicList] = useState<SavedMusic[]>([]);
|
||||||
const [loading, setLoading] = useState(false);
|
const [loading, setLoading] = useState(false);
|
||||||
const [editingId, setEditingId] = useState<string | null>(null);
|
const [editingId, setEditingId] = useState<string | null>(null);
|
||||||
|
|
@ -36,22 +37,13 @@ export function SavedMusicList({ onMusicSelect }: SavedMusicListProps) {
|
||||||
|
|
||||||
// 获取用户保存的音乐列表
|
// 获取用户保存的音乐列表
|
||||||
const fetchSavedMusic = async () => {
|
const fetchSavedMusic = async () => {
|
||||||
if (!isAuthenticated || !user || !sessionPassword) return;
|
if (!isAuthenticated || !user) return;
|
||||||
|
|
||||||
setLoading(true);
|
setLoading(true);
|
||||||
setError(null);
|
setError(null);
|
||||||
|
|
||||||
try {
|
try {
|
||||||
const response = await fetch('/api/auth/music/list', {
|
const response = await ApiClient.post('/api/auth/music/list');
|
||||||
method: 'POST',
|
|
||||||
headers: {
|
|
||||||
'Content-Type': 'application/json',
|
|
||||||
},
|
|
||||||
body: JSON.stringify({
|
|
||||||
username: user.username,
|
|
||||||
password: sessionPassword, // 使用会话密码
|
|
||||||
}),
|
|
||||||
});
|
|
||||||
|
|
||||||
if (!response.ok) {
|
if (!response.ok) {
|
||||||
throw new Error('获取音乐列表失败');
|
throw new Error('获取音乐列表失败');
|
||||||
|
|
@ -76,17 +68,9 @@ export function SavedMusicList({ onMusicSelect }: SavedMusicListProps) {
|
||||||
if (!isAuthenticated || !user) return;
|
if (!isAuthenticated || !user) return;
|
||||||
|
|
||||||
try {
|
try {
|
||||||
const response = await fetch('/api/auth/music/rename', {
|
const response = await ApiClient.post('/api/auth/music/rename', {
|
||||||
method: 'POST',
|
musicId,
|
||||||
headers: {
|
name: newName
|
||||||
'Content-Type': 'application/json',
|
|
||||||
},
|
|
||||||
body: JSON.stringify({
|
|
||||||
musicId,
|
|
||||||
name: newName,
|
|
||||||
username: user.username,
|
|
||||||
password: sessionPassword,
|
|
||||||
}),
|
|
||||||
});
|
});
|
||||||
|
|
||||||
if (!response.ok) {
|
if (!response.ok) {
|
||||||
|
|
@ -121,16 +105,8 @@ export function SavedMusicList({ onMusicSelect }: SavedMusicListProps) {
|
||||||
}
|
}
|
||||||
|
|
||||||
try {
|
try {
|
||||||
const response = await fetch('/api/auth/music/delete', {
|
const response = await ApiClient.post('/api/auth/music/delete', {
|
||||||
method: 'POST',
|
musicId
|
||||||
headers: {
|
|
||||||
'Content-Type': 'application/json',
|
|
||||||
},
|
|
||||||
body: JSON.stringify({
|
|
||||||
musicId,
|
|
||||||
username: user.username,
|
|
||||||
password: sessionPassword,
|
|
||||||
}),
|
|
||||||
});
|
});
|
||||||
|
|
||||||
if (!response.ok) {
|
if (!response.ok) {
|
||||||
|
|
@ -221,12 +197,12 @@ export function SavedMusicList({ onMusicSelect }: SavedMusicListProps) {
|
||||||
|
|
||||||
// 当用户认证状态改变时,获取音乐列表
|
// 当用户认证状态改变时,获取音乐列表
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (isAuthenticated && user && sessionPassword) {
|
if (isAuthenticated && user) {
|
||||||
fetchSavedMusic();
|
fetchSavedMusic();
|
||||||
} else {
|
} else {
|
||||||
setSavedMusicList([]);
|
setSavedMusicList([]);
|
||||||
}
|
}
|
||||||
}, [isAuthenticated, user, sessionPassword]);
|
}, [isAuthenticated, user]);
|
||||||
|
|
||||||
// 如果用户未登录,不显示组件
|
// 如果用户未登录,不显示组件
|
||||||
if (!isAuthenticated) {
|
if (!isAuthenticated) {
|
||||||
|
|
|
||||||
|
|
@ -1,15 +1,30 @@
|
||||||
import { useMemo, useState } from 'react';
|
import { useMemo, useState, useEffect } from 'react';
|
||||||
import { AnimatePresence } from 'motion/react';
|
import { AnimatePresence, motion } from 'motion/react';
|
||||||
import { FaSave } from 'react-icons/fa/index';
|
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 { useSoundStore } from '@/stores/sound';
|
||||||
import { useLocalizedSounds } from '@/hooks/useLocalizedSounds';
|
import { useLocalizedSounds } from '@/hooks/useLocalizedSounds';
|
||||||
import { useTranslation } from '@/hooks/useTranslation';
|
import { useTranslation } from '@/hooks/useTranslation';
|
||||||
import { useAuthStore } from '@/stores/auth';
|
import { useAuthStore } from '@/stores/auth';
|
||||||
|
import { ApiClient } from '@/lib/api-client';
|
||||||
|
|
||||||
import { Sound } from '@/components/sounds/sound';
|
import { Sound } from '@/components/sounds/sound';
|
||||||
import styles from '../sounds/sounds.module.css';
|
import styles from '../sounds/sounds.module.css';
|
||||||
|
|
||||||
|
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() {
|
export function SelectedSoundsDisplay() {
|
||||||
const { t } = useTranslation();
|
const { t } = useTranslation();
|
||||||
const localizedCategories = useLocalizedSounds();
|
const localizedCategories = useLocalizedSounds();
|
||||||
|
|
@ -17,6 +32,14 @@ export function SelectedSoundsDisplay() {
|
||||||
const [isSaving, setIsSaving] = useState(false);
|
const [isSaving, setIsSaving] = useState(false);
|
||||||
const [showLoginPrompt, setShowLoginPrompt] = useState(false);
|
const [showLoginPrompt, setShowLoginPrompt] = useState(false);
|
||||||
const [showSaveSuccess, setShowSaveSuccess] = 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
|
// 获取声音store
|
||||||
const sounds = useSoundStore(state => state.sounds);
|
const sounds = useSoundStore(state => state.sounds);
|
||||||
|
|
@ -26,6 +49,217 @@ export function SelectedSoundsDisplay() {
|
||||||
Object.keys(state.sounds).filter(id => state.sounds[id].isSelected)
|
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 () => {
|
const saveMusic = async () => {
|
||||||
if (!isAuthenticated) {
|
if (!isAuthenticated) {
|
||||||
|
|
@ -33,6 +267,12 @@ export function SelectedSoundsDisplay() {
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
if (selectedSoundIds.length === 0) {
|
||||||
|
setError('请先选择声音');
|
||||||
|
setTimeout(() => setError(null), 3000);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
setIsSaving(true);
|
setIsSaving(true);
|
||||||
|
|
||||||
try {
|
try {
|
||||||
|
|
@ -50,23 +290,15 @@ export function SelectedSoundsDisplay() {
|
||||||
random_effects[sound.id] = sound.isRandomSpeed || sound.isRandomVolume || sound.isRandomRate;
|
random_effects[sound.id] = sound.isRandomSpeed || sound.isRandomVolume || sound.isRandomRate;
|
||||||
});
|
});
|
||||||
|
|
||||||
// 检查是否有sessionPassword
|
|
||||||
if (!sessionPassword) {
|
|
||||||
console.error('会话密码丢失,请重新登录');
|
|
||||||
setShowLoginPrompt(true);
|
|
||||||
setIsSaving(false);
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
const musicData = {
|
const musicData = {
|
||||||
name: `我的音乐 ${new Date().toLocaleDateString()}`,
|
name: musicName || `我的音乐 ${new Date().toLocaleDateString()}`,
|
||||||
sounds: selectedSoundIds,
|
sounds: selectedSoundIds,
|
||||||
volume,
|
volume,
|
||||||
speed,
|
speed,
|
||||||
rate,
|
rate,
|
||||||
random_effects,
|
random_effects,
|
||||||
username: user?.username,
|
username: user?.username,
|
||||||
password: sessionPassword // 使用会话密码
|
password: sessionPassword || '' // 使用会话密码,如果为空则让后端处理
|
||||||
};
|
};
|
||||||
|
|
||||||
// 调用保存API
|
// 调用保存API
|
||||||
|
|
@ -81,8 +313,9 @@ export function SelectedSoundsDisplay() {
|
||||||
if (response.ok) {
|
if (response.ok) {
|
||||||
const result = await response.json();
|
const result = await response.json();
|
||||||
setShowSaveSuccess(true);
|
setShowSaveSuccess(true);
|
||||||
setTimeout(() => setShowSaveSuccess(false), 2000);
|
|
||||||
console.log('✅ 音乐保存成功:', result.music);
|
console.log('✅ 音乐保存成功:', result.music);
|
||||||
|
// 保存成功后刷新列表
|
||||||
|
await fetchSavedMusic();
|
||||||
} else {
|
} else {
|
||||||
const errorData = await response.json();
|
const errorData = await response.json();
|
||||||
console.error('❌ 保存音乐失败:', errorData.error);
|
console.error('❌ 保存音乐失败:', errorData.error);
|
||||||
|
|
@ -90,11 +323,15 @@ export function SelectedSoundsDisplay() {
|
||||||
if (response.status === 401) {
|
if (response.status === 401) {
|
||||||
setShowLoginPrompt(true);
|
setShowLoginPrompt(true);
|
||||||
}
|
}
|
||||||
|
setError(errorData.error || '保存失败');
|
||||||
|
setTimeout(() => setError(null), 3000);
|
||||||
}
|
}
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
console.error('❌ 保存音乐失败:', error);
|
console.error('❌ 保存音乐失败:', error);
|
||||||
// 网络错误或其他异常,显示登录提示
|
// 网络错误或其他异常,显示登录提示
|
||||||
setShowLoginPrompt(true);
|
setShowLoginPrompt(true);
|
||||||
|
setError('保存失败,请重试');
|
||||||
|
setTimeout(() => setError(null), 3000);
|
||||||
} finally {
|
} finally {
|
||||||
setIsSaving(false);
|
setIsSaving(false);
|
||||||
}
|
}
|
||||||
|
|
@ -111,6 +348,26 @@ export function SelectedSoundsDisplay() {
|
||||||
.filter(Boolean);
|
.filter(Boolean);
|
||||||
}, [selectedSoundIds, localizedCategories]);
|
}, [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) {
|
if (selectedSounds.length === 0) {
|
||||||
return null;
|
return null;
|
||||||
|
|
@ -118,6 +375,23 @@ export function SelectedSoundsDisplay() {
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className={styles.soundsContainer}>
|
<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}>
|
<div className={styles.sounds}>
|
||||||
<AnimatePresence initial={false}>
|
<AnimatePresence initial={false}>
|
||||||
{selectedSounds.map((sound) => (
|
{selectedSounds.map((sound) => (
|
||||||
|
|
@ -137,47 +411,170 @@ export function SelectedSoundsDisplay() {
|
||||||
</AnimatePresence>
|
</AnimatePresence>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{/* 保存按钮区域 */}
|
{/* 音乐列表区域 - 只有登录用户才显示 */}
|
||||||
<div className={styles.saveSection}>
|
{isAuthenticated && (
|
||||||
<button
|
<div className={styles.musicSection}>
|
||||||
className={`${styles.saveButton} ${isSaving ? styles.saving : ''}`}
|
<div className={styles.musicHeader}>
|
||||||
onClick={saveMusic}
|
<h4 className={styles.musicTitle}>
|
||||||
disabled={isSaving || selectedSounds.length === 0}
|
<FaCog className={styles.musicIcon} />
|
||||||
title={isAuthenticated ? '保存当前音乐配置' : '请先登录后再保存'}
|
我的音乐
|
||||||
>
|
</h4>
|
||||||
<FaSave />
|
|
||||||
<span>
|
|
||||||
{isSaving ? '保存中...' : '保存音乐'}
|
|
||||||
</span>
|
|
||||||
</button>
|
|
||||||
|
|
||||||
{/* 保存成功提示 */}
|
|
||||||
{showSaveSuccess && (
|
|
||||||
<div className={styles.saveSuccess}>
|
|
||||||
✓ 音乐保存成功!
|
|
||||||
</div>
|
</div>
|
||||||
)}
|
|
||||||
|
|
||||||
{/* 登录提示 */}
|
{/* 错误提示 */}
|
||||||
{showLoginPrompt && (
|
{error && (
|
||||||
<div className={styles.loginPrompt}>
|
<div className={styles.error}>
|
||||||
<p>请先登录后再保存音乐</p>
|
{error}
|
||||||
<button
|
<button onClick={() => setError(null)} className={styles.errorClose}>×</button>
|
||||||
onClick={() => {
|
</div>
|
||||||
setShowLoginPrompt(false);
|
)}
|
||||||
// 触发LanguageSwitcher的登录表单
|
|
||||||
const event = new CustomEvent('showLoginForm', { bubbles: true });
|
{/* 保存成功提示 */}
|
||||||
document.dispatchEvent(event);
|
{showSaveSuccess && (
|
||||||
}}
|
<div className={styles.saveSuccess}>
|
||||||
>
|
<p>✓ 音乐保存成功!</p>
|
||||||
去登录
|
<button onClick={() => setShowSaveSuccess(false)}>
|
||||||
</button>
|
确定
|
||||||
<button onClick={() => setShowLoginPrompt(false)}>
|
</button>
|
||||||
取消
|
</div>
|
||||||
</button>
|
)}
|
||||||
|
|
||||||
|
{/* 音乐列表 - 自动显示 */}
|
||||||
|
<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>
|
||||||
</div>
|
)}
|
||||||
|
|
||||||
|
{/* 登录提示 */}
|
||||||
|
{showLoginPrompt && (
|
||||||
|
<div className={styles.loginPrompt}>
|
||||||
|
<p>请先登录后再保存音乐</p>
|
||||||
|
<button
|
||||||
|
onClick={() => {
|
||||||
|
setShowLoginPrompt(false);
|
||||||
|
// 触发LanguageSwitcher的登录表单
|
||||||
|
const event = new CustomEvent('showLoginForm', { bubbles: true });
|
||||||
|
document.dispatchEvent(event);
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
去登录
|
||||||
|
</button>
|
||||||
|
<button onClick={() => setShowLoginPrompt(false)}>
|
||||||
|
取消
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
@ -27,13 +27,15 @@
|
||||||
width: 16px;
|
width: 16px;
|
||||||
height: 16px;
|
height: 16px;
|
||||||
cursor: pointer;
|
cursor: pointer;
|
||||||
background: var(--color-neutral-950);
|
background: var(--bg-tertiary);
|
||||||
|
border: 1px solid var(--color-border);
|
||||||
border-radius: 50%;
|
border-radius: 50%;
|
||||||
box-shadow: 0 0 3px var(--color-neutral-50);
|
box-shadow: 0 0 3px var(--color-neutral-50);
|
||||||
}
|
}
|
||||||
|
|
||||||
.sliderThumb:hover {
|
.sliderThumb:hover {
|
||||||
background: var(--color-neutral-800);
|
background: var(--bg-secondary);
|
||||||
|
border-color: var(--color-foreground);
|
||||||
}
|
}
|
||||||
|
|
||||||
.sliderThumb:focus {
|
.sliderThumb:focus {
|
||||||
|
|
|
||||||
|
|
@ -74,9 +74,10 @@
|
||||||
height: 14px;
|
height: 14px;
|
||||||
margin-top: -3px;
|
margin-top: -3px;
|
||||||
appearance: none;
|
appearance: none;
|
||||||
background-color: var(--bg-tertiary);
|
background-color: var(--color-neutral-700);
|
||||||
border: 1px solid var(--color-border);
|
border: 1px solid var(--color-border);
|
||||||
border-radius: 50%;
|
border-radius: 50%;
|
||||||
|
box-shadow: 0 0 2px var(--color-neutral-400);
|
||||||
}
|
}
|
||||||
|
|
||||||
&:not(:disabled):focus::-webkit-slider-thumb {
|
&:not(:disabled):focus::-webkit-slider-thumb {
|
||||||
|
|
@ -97,11 +98,12 @@
|
||||||
width: 14px;
|
width: 14px;
|
||||||
height: 14px;
|
height: 14px;
|
||||||
margin-top: -3px;
|
margin-top: -3px;
|
||||||
background-color: var(--bg-tertiary);
|
background-color: var(--color-neutral-700);
|
||||||
border: none;
|
border: none;
|
||||||
border: 1px solid var(--color-border);
|
border: 1px solid var(--color-border);
|
||||||
border-radius: 0;
|
border-radius: 0;
|
||||||
border-radius: 50%;
|
border-radius: 50%;
|
||||||
|
box-shadow: 0 0 2px var(--color-neutral-400);
|
||||||
}
|
}
|
||||||
|
|
||||||
&:not(:disabled):focus::-moz-range-thumb {
|
&:not(:disabled):focus::-moz-range-thumb {
|
||||||
|
|
|
||||||
|
|
@ -8,8 +8,12 @@
|
||||||
.soundsContainer {
|
.soundsContainer {
|
||||||
display: flex;
|
display: flex;
|
||||||
flex-direction: column;
|
flex-direction: column;
|
||||||
gap: 20px;
|
gap: 16px;
|
||||||
margin-top: 20px;
|
margin-top: 20px;
|
||||||
|
background: var(--bg-secondary);
|
||||||
|
border-radius: 12px;
|
||||||
|
border: 1px solid var(--color-border);
|
||||||
|
padding: 16px;
|
||||||
}
|
}
|
||||||
|
|
||||||
.saveSection {
|
.saveSection {
|
||||||
|
|
@ -28,7 +32,7 @@
|
||||||
display: flex;
|
display: flex;
|
||||||
align-items: center;
|
align-items: center;
|
||||||
gap: 8px;
|
gap: 8px;
|
||||||
padding: 12px 20px;
|
padding: 10px 16px;
|
||||||
background: linear-gradient(135deg, #10b981, #059669);
|
background: linear-gradient(135deg, #10b981, #059669);
|
||||||
color: white;
|
color: white;
|
||||||
border: none;
|
border: none;
|
||||||
|
|
@ -37,6 +41,346 @@
|
||||||
font-weight: 500;
|
font-weight: 500;
|
||||||
cursor: pointer;
|
cursor: pointer;
|
||||||
transition: all 0.2s ease;
|
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) {
|
.saveButton:hover:not(:disabled) {
|
||||||
|
|
@ -56,13 +400,48 @@
|
||||||
}
|
}
|
||||||
|
|
||||||
.saveSuccess {
|
.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;
|
padding: 8px 16px;
|
||||||
background: linear-gradient(135deg, #10b981, #059669);
|
margin: 0 4px;
|
||||||
color: white;
|
border: none;
|
||||||
border-radius: 6px;
|
border-radius: 4px;
|
||||||
font-size: 13px;
|
font-size: 13px;
|
||||||
font-weight: 500;
|
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 {
|
.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 type { APIRoute } from 'astro';
|
||||||
import { authenticateUser } from '@/lib/database';
|
import { authenticateUser } from '@/lib/database';
|
||||||
|
import { createJWT } from '@/lib/jwt';
|
||||||
|
|
||||||
export const POST: APIRoute = async ({ request }) => {
|
export const POST: APIRoute = async ({ request }) => {
|
||||||
try {
|
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({
|
return new Response(JSON.stringify({
|
||||||
success: true,
|
success: true,
|
||||||
user: {
|
user: {
|
||||||
id: user.id,
|
id: user.id,
|
||||||
username: user.username,
|
username: user.username,
|
||||||
created_at: user.created_at
|
created_at: user.created_at
|
||||||
}
|
},
|
||||||
|
token,
|
||||||
|
expiresIn: 7 * 24 * 60 * 60 // 7天,单位:秒
|
||||||
}), {
|
}), {
|
||||||
status: 200,
|
status: 200,
|
||||||
headers: { 'Content-Type': 'application/json' },
|
headers: { 'Content-Type': 'application/json' },
|
||||||
|
|
|
||||||
|
|
@ -1,44 +1,50 @@
|
||||||
import type { APIRoute } from 'astro';
|
import type { APIRoute } from 'astro';
|
||||||
import { deleteMusic } from '@/lib/database';
|
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 }) => {
|
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 {
|
try {
|
||||||
const body = await request.text();
|
const { user } = authResult;
|
||||||
|
const { data } = bodyResult;
|
||||||
|
const { musicId } = data;
|
||||||
|
|
||||||
if (!body.trim()) {
|
// 验证音乐ID
|
||||||
return new Response(JSON.stringify({ error: '请求体不能为空' }), {
|
if (!musicId || (typeof musicId !== 'string' && typeof musicId !== 'number')) {
|
||||||
|
return new Response(JSON.stringify({
|
||||||
|
error: '音乐ID不能为空且必须是有效的标识符'
|
||||||
|
}), {
|
||||||
status: 400,
|
status: 400,
|
||||||
headers: { 'Content-Type': 'application/json' },
|
headers: { 'Content-Type': 'application/json' }
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
const { musicId, username, password } = JSON.parse(body);
|
|
||||||
|
|
||||||
// 验证输入
|
|
||||||
if (!musicId || !username || !password) {
|
|
||||||
return new Response(JSON.stringify({ error: '音乐ID和用户信息不能为空' }), {
|
|
||||||
status: 400,
|
|
||||||
headers: { 'Content-Type': 'application/json' },
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
// 验证用户身份
|
|
||||||
const user = authenticateUser(username, password);
|
|
||||||
if (!user) {
|
|
||||||
return new Response(JSON.stringify({ error: '用户认证失败' }), {
|
|
||||||
status: 401,
|
|
||||||
headers: { 'Content-Type': 'application/json' },
|
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
// 删除音乐记录
|
// 删除音乐记录
|
||||||
const success = deleteMusic(musicId, user.id);
|
const success = deleteMusic(musicId.toString(), user!.id);
|
||||||
|
|
||||||
if (!success) {
|
if (!success) {
|
||||||
return new Response(JSON.stringify({ error: '音乐不存在或无权限删除' }), {
|
return new Response(JSON.stringify({
|
||||||
|
error: '音乐不存在或无权限删除'
|
||||||
|
}), {
|
||||||
status: 404,
|
status: 404,
|
||||||
headers: { 'Content-Type': 'application/json' },
|
headers: { 'Content-Type': 'application/json' }
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -47,23 +53,10 @@ export const POST: APIRoute = async ({ request }) => {
|
||||||
message: '音乐删除成功'
|
message: '音乐删除成功'
|
||||||
}), {
|
}), {
|
||||||
status: 200,
|
status: 200,
|
||||||
headers: { 'Content-Type': 'application/json' },
|
headers: { 'Content-Type': 'application/json' }
|
||||||
});
|
});
|
||||||
|
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
console.error('删除音乐错误:', error);
|
return handleApiError(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' },
|
|
||||||
});
|
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
@ -1,75 +1,63 @@
|
||||||
import type { APIRoute } from 'astro';
|
import type { APIRoute } from 'astro';
|
||||||
import { getUserMusic } from '@/lib/database';
|
import { getUserMusic } from '@/lib/database';
|
||||||
import { authenticateUser } from '@/lib/database';
|
import { authenticateJWTRequest, handleApiError } from '@/lib/jwt-auth-middleware';
|
||||||
|
|
||||||
export const POST: APIRoute = async ({ request }) => {
|
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 {
|
try {
|
||||||
const body = await request.text();
|
const { user } = authResult;
|
||||||
|
|
||||||
if (!body.trim()) {
|
|
||||||
return new Response(JSON.stringify({ error: '请求体不能为空' }), {
|
|
||||||
status: 400,
|
|
||||||
headers: { 'Content-Type': 'application/json' },
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
const { username, password } = JSON.parse(body);
|
|
||||||
|
|
||||||
// 验证输入
|
|
||||||
if (!username || !password) {
|
|
||||||
return new Response(JSON.stringify({ error: '用户名和密码不能为空' }), {
|
|
||||||
status: 400,
|
|
||||||
headers: { 'Content-Type': 'application/json' },
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
// 验证用户身份
|
|
||||||
const user = authenticateUser(username, password);
|
|
||||||
if (!user) {
|
|
||||||
return new Response(JSON.stringify({ error: '用户认证失败' }), {
|
|
||||||
status: 401,
|
|
||||||
headers: { 'Content-Type': 'application/json' },
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
// 获取用户音乐列表
|
// 获取用户音乐列表
|
||||||
const musicList = getUserMusic(user.id);
|
const musicList = getUserMusic(user!.id);
|
||||||
|
|
||||||
// 解析JSON字段
|
// 解析JSON字段并格式化数据
|
||||||
const formattedMusicList = musicList.map(music => ({
|
const formattedMusicList = musicList.map(music => {
|
||||||
id: music.id,
|
try {
|
||||||
name: music.name,
|
return {
|
||||||
sounds: JSON.parse(music.sounds),
|
id: music.id,
|
||||||
volume: JSON.parse(music.volume),
|
name: music.name,
|
||||||
speed: JSON.parse(music.speed),
|
sounds: JSON.parse(music.sounds),
|
||||||
rate: JSON.parse(music.rate),
|
volume: JSON.parse(music.volume),
|
||||||
random_effects: JSON.parse(music.random_effects),
|
speed: JSON.parse(music.speed),
|
||||||
created_at: music.created_at,
|
rate: JSON.parse(music.rate),
|
||||||
updated_at: music.updated_at
|
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({
|
return new Response(JSON.stringify({
|
||||||
success: true,
|
success: true,
|
||||||
musicList: formattedMusicList
|
musicList: formattedMusicList
|
||||||
}), {
|
}), {
|
||||||
status: 200,
|
status: 200,
|
||||||
headers: { 'Content-Type': 'application/json' },
|
headers: { 'Content-Type': 'application/json' }
|
||||||
});
|
});
|
||||||
|
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
console.error('获取音乐列表错误:', error);
|
return handleApiError(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' },
|
|
||||||
});
|
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
@ -1,44 +1,69 @@
|
||||||
import type { APIRoute } from 'astro';
|
import type { APIRoute } from 'astro';
|
||||||
import { updateMusicName } from '@/lib/database';
|
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 }) => {
|
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 {
|
try {
|
||||||
const body = await request.text();
|
const { user } = authResult;
|
||||||
|
const { data } = bodyResult;
|
||||||
if (!body.trim()) {
|
const { musicId, name } = data;
|
||||||
return new Response(JSON.stringify({ error: '请求体不能为空' }), {
|
|
||||||
status: 400,
|
|
||||||
headers: { 'Content-Type': 'application/json' },
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
const { musicId, name, username, password } = JSON.parse(body);
|
|
||||||
|
|
||||||
// 验证输入
|
// 验证输入
|
||||||
if (!musicId || !name || !username || !password) {
|
if (!musicId || (typeof musicId !== 'string' && typeof musicId !== 'number')) {
|
||||||
return new Response(JSON.stringify({ error: '音乐ID、新名称和用户信息不能为空' }), {
|
return new Response(JSON.stringify({
|
||||||
|
error: '音乐ID不能为空且必须是有效的标识符'
|
||||||
|
}), {
|
||||||
status: 400,
|
status: 400,
|
||||||
headers: { 'Content-Type': 'application/json' },
|
headers: { 'Content-Type': 'application/json' }
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
// 验证用户身份
|
if (!name || typeof name !== 'string' || name.trim().length === 0) {
|
||||||
const user = authenticateUser(username, password);
|
return new Response(JSON.stringify({
|
||||||
if (!user) {
|
error: '音乐名称不能为空且必须是有效的字符串'
|
||||||
return new Response(JSON.stringify({ error: '用户认证失败' }), {
|
}), {
|
||||||
status: 401,
|
status: 400,
|
||||||
headers: { 'Content-Type': 'application/json' },
|
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) {
|
if (!success) {
|
||||||
return new Response(JSON.stringify({ error: '音乐不存在或无权限修改' }), {
|
return new Response(JSON.stringify({
|
||||||
|
error: '音乐不存在或无权限修改'
|
||||||
|
}), {
|
||||||
status: 404,
|
status: 404,
|
||||||
headers: { 'Content-Type': 'application/json' },
|
headers: { 'Content-Type': 'application/json' }
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -47,23 +72,10 @@ export const POST: APIRoute = async ({ request }) => {
|
||||||
message: '音乐名称更新成功'
|
message: '音乐名称更新成功'
|
||||||
}), {
|
}), {
|
||||||
status: 200,
|
status: 200,
|
||||||
headers: { 'Content-Type': 'application/json' },
|
headers: { 'Content-Type': 'application/json' }
|
||||||
});
|
});
|
||||||
|
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
console.error('重命名音乐错误:', error);
|
return handleApiError(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' },
|
|
||||||
});
|
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
@ -1,40 +1,44 @@
|
||||||
import type { APIRoute } from 'astro';
|
import type { APIRoute } from 'astro';
|
||||||
import { createMusic } from '@/lib/database';
|
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 }) => {
|
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 {
|
try {
|
||||||
const body = await request.text();
|
const { user } = authResult;
|
||||||
|
const { data } = bodyResult;
|
||||||
if (!body.trim()) {
|
const { name, sounds, volume, speed, rate, random_effects } = data;
|
||||||
return new Response(JSON.stringify({ error: '请求体不能为空' }), {
|
|
||||||
status: 400,
|
|
||||||
headers: { 'Content-Type': 'application/json' },
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
const { name, sounds, volume, speed, rate, random_effects, username, password } = JSON.parse(body);
|
|
||||||
|
|
||||||
// 验证输入
|
// 验证输入
|
||||||
if (!name || !sounds || !username || !password) {
|
if (!name || !sounds || !Array.isArray(sounds)) {
|
||||||
return new Response(JSON.stringify({ error: '音乐名称、声音配置和用户信息不能为空' }), {
|
return new Response(JSON.stringify({
|
||||||
|
error: '音乐名称和声音配置不能为空,声音必须是数组'
|
||||||
|
}), {
|
||||||
status: 400,
|
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' },
|
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
// 创建音乐记录
|
// 创建音乐记录
|
||||||
const music = await createMusic({
|
const music = await createMusic({
|
||||||
user_id: user.id,
|
user_id: user!.id,
|
||||||
name,
|
name,
|
||||||
sounds,
|
sounds,
|
||||||
volume: volume || {},
|
volume: volume || {},
|
||||||
|
|
@ -45,6 +49,7 @@ export const POST: APIRoute = async ({ request }) => {
|
||||||
|
|
||||||
return new Response(JSON.stringify({
|
return new Response(JSON.stringify({
|
||||||
success: true,
|
success: true,
|
||||||
|
message: '音乐保存成功',
|
||||||
music: {
|
music: {
|
||||||
id: music.id,
|
id: music.id,
|
||||||
name: music.name,
|
name: music.name,
|
||||||
|
|
@ -52,23 +57,10 @@ export const POST: APIRoute = async ({ request }) => {
|
||||||
}
|
}
|
||||||
}), {
|
}), {
|
||||||
status: 201,
|
status: 201,
|
||||||
headers: { 'Content-Type': 'application/json' },
|
headers: { 'Content-Type': 'application/json' }
|
||||||
});
|
});
|
||||||
|
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
console.error('保存音乐错误:', error);
|
return handleApiError(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' },
|
|
||||||
});
|
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
@ -1,5 +1,6 @@
|
||||||
import type { APIRoute } from 'astro';
|
import type { APIRoute } from 'astro';
|
||||||
import { createUser } from '@/lib/database';
|
import { createUser } from '@/lib/database';
|
||||||
|
import { createJWT } from '@/lib/jwt';
|
||||||
|
|
||||||
export const POST: APIRoute = async ({ request }) => {
|
export const POST: APIRoute = async ({ request }) => {
|
||||||
try {
|
try {
|
||||||
|
|
@ -39,13 +40,21 @@ export const POST: APIRoute = async ({ request }) => {
|
||||||
// 创建用户
|
// 创建用户
|
||||||
const user = await createUser({ username, password });
|
const user = await createUser({ username, password });
|
||||||
|
|
||||||
|
// 创建JWT token
|
||||||
|
const token = createJWT({
|
||||||
|
userId: user.id,
|
||||||
|
username: user.username
|
||||||
|
});
|
||||||
|
|
||||||
return new Response(JSON.stringify({
|
return new Response(JSON.stringify({
|
||||||
success: true,
|
success: true,
|
||||||
user: {
|
user: {
|
||||||
id: user.id,
|
id: user.id,
|
||||||
username: user.username,
|
username: user.username,
|
||||||
created_at: user.created_at
|
created_at: user.created_at
|
||||||
}
|
},
|
||||||
|
token,
|
||||||
|
expiresIn: 7 * 24 * 60 * 60 // 7天,单位:秒
|
||||||
}), {
|
}), {
|
||||||
status: 201,
|
status: 201,
|
||||||
headers: { 'Content-Type': 'application/json' },
|
headers: { 'Content-Type': 'application/json' },
|
||||||
|
|
|
||||||
|
|
@ -12,7 +12,7 @@ interface AuthState {
|
||||||
isAuthenticated: boolean;
|
isAuthenticated: boolean;
|
||||||
isLoading: boolean;
|
isLoading: boolean;
|
||||||
error: string | null;
|
error: string | null;
|
||||||
sessionPassword: string | null; // 仅当前会话使用的密码,不持久化
|
token: string | null; // JWT token
|
||||||
}
|
}
|
||||||
|
|
||||||
interface AuthStore extends AuthState {
|
interface AuthStore extends AuthState {
|
||||||
|
|
@ -23,6 +23,8 @@ interface AuthStore extends AuthState {
|
||||||
clearError: () => void;
|
clearError: () => void;
|
||||||
setLoading: (loading: boolean) => void;
|
setLoading: (loading: boolean) => void;
|
||||||
checkAuth: () => Promise<void>;
|
checkAuth: () => Promise<void>;
|
||||||
|
getToken: () => string | null;
|
||||||
|
setToken: (token: string) => void;
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
|
|
@ -57,7 +59,7 @@ export const useAuthStore = create<AuthStore>()(
|
||||||
isAuthenticated: false,
|
isAuthenticated: false,
|
||||||
isLoading: false,
|
isLoading: false,
|
||||||
error: null,
|
error: null,
|
||||||
sessionPassword: null,
|
token: null,
|
||||||
|
|
||||||
// Actions
|
// Actions
|
||||||
login: async (userData) => {
|
login: async (userData) => {
|
||||||
|
|
@ -66,20 +68,14 @@ export const useAuthStore = create<AuthStore>()(
|
||||||
try {
|
try {
|
||||||
const result = await apiCall('/api/auth/login', userData);
|
const result = await apiCall('/api/auth/login', userData);
|
||||||
const user = result.user;
|
const user = result.user;
|
||||||
|
const token = result.token;
|
||||||
|
|
||||||
set({
|
set({
|
||||||
user,
|
user,
|
||||||
isAuthenticated: true,
|
isAuthenticated: true,
|
||||||
isLoading: false,
|
isLoading: false,
|
||||||
error: null,
|
error: null,
|
||||||
});
|
token,
|
||||||
|
|
||||||
set({
|
|
||||||
user,
|
|
||||||
isAuthenticated: true,
|
|
||||||
isLoading: false,
|
|
||||||
error: null,
|
|
||||||
sessionPassword: userData.password, // 保存密码用于当前会话的API调用
|
|
||||||
});
|
});
|
||||||
|
|
||||||
console.log('✅ 用户登录成功:', user.username);
|
console.log('✅ 用户登录成功:', user.username);
|
||||||
|
|
@ -90,7 +86,7 @@ export const useAuthStore = create<AuthStore>()(
|
||||||
isAuthenticated: false,
|
isAuthenticated: false,
|
||||||
isLoading: false,
|
isLoading: false,
|
||||||
error: errorMessage,
|
error: errorMessage,
|
||||||
sessionPassword: null,
|
token: null,
|
||||||
});
|
});
|
||||||
console.error('❌ 登录失败:', error);
|
console.error('❌ 登录失败:', error);
|
||||||
throw error;
|
throw error;
|
||||||
|
|
@ -103,20 +99,14 @@ export const useAuthStore = create<AuthStore>()(
|
||||||
try {
|
try {
|
||||||
const result = await apiCall('/api/auth/register', userData);
|
const result = await apiCall('/api/auth/register', userData);
|
||||||
const user = result.user;
|
const user = result.user;
|
||||||
|
const token = result.token;
|
||||||
|
|
||||||
set({
|
set({
|
||||||
user,
|
user,
|
||||||
isAuthenticated: true,
|
isAuthenticated: true,
|
||||||
isLoading: false,
|
isLoading: false,
|
||||||
error: null,
|
error: null,
|
||||||
});
|
token,
|
||||||
|
|
||||||
set({
|
|
||||||
user,
|
|
||||||
isAuthenticated: true,
|
|
||||||
isLoading: false,
|
|
||||||
error: null,
|
|
||||||
sessionPassword: userData.password, // 保存密码用于当前会话的API调用
|
|
||||||
});
|
});
|
||||||
|
|
||||||
console.log('✅ 用户注册成功:', user.username);
|
console.log('✅ 用户注册成功:', user.username);
|
||||||
|
|
@ -127,7 +117,7 @@ export const useAuthStore = create<AuthStore>()(
|
||||||
isAuthenticated: false,
|
isAuthenticated: false,
|
||||||
isLoading: false,
|
isLoading: false,
|
||||||
error: errorMessage,
|
error: errorMessage,
|
||||||
sessionPassword: null,
|
token: null,
|
||||||
});
|
});
|
||||||
console.error('❌ 注册失败:', error);
|
console.error('❌ 注册失败:', error);
|
||||||
throw error;
|
throw error;
|
||||||
|
|
@ -140,7 +130,7 @@ export const useAuthStore = create<AuthStore>()(
|
||||||
isAuthenticated: false,
|
isAuthenticated: false,
|
||||||
isLoading: false,
|
isLoading: false,
|
||||||
error: null,
|
error: null,
|
||||||
sessionPassword: null, // 清除会话密码
|
token: null,
|
||||||
});
|
});
|
||||||
console.log('✅ 用户已登出');
|
console.log('✅ 用户已登出');
|
||||||
},
|
},
|
||||||
|
|
@ -154,37 +144,33 @@ export const useAuthStore = create<AuthStore>()(
|
||||||
},
|
},
|
||||||
|
|
||||||
checkAuth: async () => {
|
checkAuth: async () => {
|
||||||
const { user, isAuthenticated } = get();
|
const { user, isAuthenticated, token } = get();
|
||||||
|
|
||||||
// 如果已经有用户信息且已认证,则直接返回
|
// 如果已经有用户信息且已认证,则直接返回
|
||||||
if (user && isAuthenticated) {
|
if (user && isAuthenticated && token) {
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
// 这里可以添加token验证或会话验证逻辑
|
// zustand persist会自动从localStorage恢复token
|
||||||
// 目前简单检查本地存储的用户信息
|
// 如果有token但没有用户信息,说明token可能无效
|
||||||
set({ isLoading: true });
|
if (token && !user) {
|
||||||
|
console.warn('发现token但缺少用户信息,可能需要重新登录');
|
||||||
try {
|
|
||||||
// 如果有用户信息但未认证,可以尝试验证
|
|
||||||
if (user && !isAuthenticated) {
|
|
||||||
set({
|
|
||||||
isAuthenticated: true,
|
|
||||||
isLoading: false,
|
|
||||||
error: null,
|
|
||||||
});
|
|
||||||
} else {
|
|
||||||
set({ isLoading: false });
|
|
||||||
}
|
|
||||||
} catch (error) {
|
|
||||||
console.error('❌ 认证检查失败:', error);
|
|
||||||
set({
|
set({
|
||||||
user: null,
|
token: null,
|
||||||
isAuthenticated: false,
|
isAuthenticated: false
|
||||||
isLoading: false,
|
|
||||||
error: '认证检查失败',
|
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
|
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) => ({
|
partialize: (state) => ({
|
||||||
user: state.user,
|
user: state.user,
|
||||||
isAuthenticated: state.isAuthenticated,
|
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