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:
zl 2025-11-17 23:04:58 +08:00
parent e01092d97e
commit 010fb9674b
27 changed files with 2454 additions and 321 deletions

Binary file not shown.

View file

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

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

View 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>
);
}

View 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 中 */

View 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}
/>
</>
);
}

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

View 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>
);
}

View file

@ -5,6 +5,7 @@ import { AnimatePresence } from 'motion/react';
import { useAuthStore } from '@/stores/auth';
import { useSoundStore } from '@/stores/sound';
import { useTranslation } from '@/hooks/useTranslation';
import { ApiClient } from '@/lib/api-client';
import type { SavedMusic } from '@/lib/database';
@ -16,7 +17,7 @@ interface SavedMusicListProps {
export function SavedMusicList({ onMusicSelect }: SavedMusicListProps) {
const { t } = useTranslation();
const { isAuthenticated, user, sessionPassword } = useAuthStore();
const { isAuthenticated, user } = useAuthStore();
const [savedMusicList, setSavedMusicList] = useState<SavedMusic[]>([]);
const [loading, setLoading] = useState(false);
const [editingId, setEditingId] = useState<string | null>(null);
@ -36,22 +37,13 @@ export function SavedMusicList({ onMusicSelect }: SavedMusicListProps) {
// 获取用户保存的音乐列表
const fetchSavedMusic = async () => {
if (!isAuthenticated || !user || !sessionPassword) return;
if (!isAuthenticated || !user) return;
setLoading(true);
setError(null);
try {
const response = await fetch('/api/auth/music/list', {
method: 'POST',
headers: {
'Content-Type': 'application/json',
},
body: JSON.stringify({
username: user.username,
password: sessionPassword, // 使用会话密码
}),
});
const response = await ApiClient.post('/api/auth/music/list');
if (!response.ok) {
throw new Error('获取音乐列表失败');
@ -76,17 +68,9 @@ export function SavedMusicList({ onMusicSelect }: SavedMusicListProps) {
if (!isAuthenticated || !user) return;
try {
const response = await fetch('/api/auth/music/rename', {
method: 'POST',
headers: {
'Content-Type': 'application/json',
},
body: JSON.stringify({
const response = await ApiClient.post('/api/auth/music/rename', {
musicId,
name: newName,
username: user.username,
password: sessionPassword,
}),
name: newName
});
if (!response.ok) {
@ -121,16 +105,8 @@ export function SavedMusicList({ onMusicSelect }: SavedMusicListProps) {
}
try {
const response = await fetch('/api/auth/music/delete', {
method: 'POST',
headers: {
'Content-Type': 'application/json',
},
body: JSON.stringify({
musicId,
username: user.username,
password: sessionPassword,
}),
const response = await ApiClient.post('/api/auth/music/delete', {
musicId
});
if (!response.ok) {
@ -221,12 +197,12 @@ export function SavedMusicList({ onMusicSelect }: SavedMusicListProps) {
// 当用户认证状态改变时,获取音乐列表
useEffect(() => {
if (isAuthenticated && user && sessionPassword) {
if (isAuthenticated && user) {
fetchSavedMusic();
} else {
setSavedMusicList([]);
}
}, [isAuthenticated, user, sessionPassword]);
}, [isAuthenticated, user]);
// 如果用户未登录,不显示组件
if (!isAuthenticated) {

View file

@ -1,15 +1,30 @@
import { useMemo, useState } from 'react';
import { AnimatePresence } from 'motion/react';
import { FaSave } from 'react-icons/fa/index';
import { useMemo, useState, useEffect } from 'react';
import { AnimatePresence, motion } from 'motion/react';
import { FaSave, FaPlay, FaTrash, FaEdit, FaCog, FaSignOutAlt, FaMusic } from 'react-icons/fa/index';
import { SaveMusicButton } from '@/components/buttons/save-music/save-music';
import { DeleteMusicButton } from '@/components/buttons/delete-music/delete-music';
import { useSoundStore } from '@/stores/sound';
import { useLocalizedSounds } from '@/hooks/useLocalizedSounds';
import { useTranslation } from '@/hooks/useTranslation';
import { useAuthStore } from '@/stores/auth';
import { ApiClient } from '@/lib/api-client';
import { Sound } from '@/components/sounds/sound';
import styles from '../sounds/sounds.module.css';
interface SavedMusic {
id: number;
name: string;
sounds: string[];
volume: Record<string, number>;
speed: Record<string, number>;
rate: Record<string, number>;
random_effects: Record<string, boolean>;
created_at: string;
updated_at: string;
}
export function SelectedSoundsDisplay() {
const { t } = useTranslation();
const localizedCategories = useLocalizedSounds();
@ -17,6 +32,14 @@ export function SelectedSoundsDisplay() {
const [isSaving, setIsSaving] = useState(false);
const [showLoginPrompt, setShowLoginPrompt] = useState(false);
const [showSaveSuccess, setShowSaveSuccess] = useState(false);
const [savedMusicList, setSavedMusicList] = useState<SavedMusic[]>([]);
const [isLoadingMusic, setIsLoadingMusic] = useState(false);
const [editingId, setEditingId] = useState<string | null>(null);
const [editingName, setEditingName] = useState('');
const [showMusicDropdown, setShowMusicDropdown] = useState(true); // 默认展开
const [expandedMusic, setExpandedMusic] = useState<Set<number>>(new Set()); // 跟踪展开的音乐项
const [error, setError] = useState<string | null>(null);
const [musicName, setMusicName] = useState('');
// 获取声音store
const sounds = useSoundStore(state => state.sounds);
@ -26,6 +49,217 @@ export function SelectedSoundsDisplay() {
Object.keys(state.sounds).filter(id => state.sounds[id].isSelected)
);
// 获取声音store的操作函数
const unselectAll = useSoundStore(state => state.unselectAll);
const select = useSoundStore(state => state.select);
const setVolume = useSoundStore(state => state.setVolume);
const setSpeed = useSoundStore(state => state.setSpeed);
const setRate = useSoundStore(state => state.setRate);
const toggleRandomSpeed = useSoundStore(state => state.toggleRandomSpeed);
const toggleRandomVolume = useSoundStore(state => state.toggleRandomVolume);
const toggleRandomRate = useSoundStore(state => state.toggleRandomRate);
const play = useSoundStore(state => state.play);
// 获取用户保存的音乐列表
const fetchSavedMusic = async () => {
console.log('🔍 fetchSavedMusic 被调用');
console.log('🔐 认证状态:', { isAuthenticated, user: user?.username });
if (!isAuthenticated || !user) {
console.log('❌ 用户未认证,退出获取音乐列表');
setSavedMusicList([]);
return;
}
setIsLoadingMusic(true);
setError(null);
try {
console.log('🔍 开始获取音乐列表,用户:', user.username);
// 检查localStorage中的token
const authStorage = localStorage.getItem('auth-storage');
console.log('🗄️ localStorage中的auth-storage:', authStorage);
if (authStorage) {
try {
const parsed = JSON.parse(authStorage);
console.log('🔑 parsed state token:', parsed.state?.token ? '存在' : '不存在');
console.log('🔑 parsed state user:', parsed.state?.user?.username);
} catch (e) {
console.error('解析auth-storage失败:', e);
}
}
// 检查store中的token
const storeToken = useAuthStore.getState().getToken();
console.log('🏪 store中的token:', storeToken ? '存在' : '不存在');
const response = await ApiClient.post('/api/auth/music/list');
console.log('📡 音乐列表API响应状态:', response.status);
console.log('📡 响应头:', response.headers);
if (!response.ok) {
const errorText = await response.text();
console.error('❌ API响应错误:', response.status, errorText);
throw new Error(`获取音乐列表失败 (${response.status}): ${errorText}`);
}
const data = await response.json();
console.log('📋 音乐列表数据:', data);
if (data.success) {
console.log('✅ 设置音乐列表:', data.musicList || [], '数量:', (data.musicList || []).length);
setSavedMusicList(data.musicList || []);
console.log('✅ savedMusicList状态更新完成');
} else {
setError(data.error || '获取音乐列表失败');
console.error('❌ 音乐列表API返回错误:', data.error);
}
} catch (error) {
console.error('❌ 获取音乐列表失败:', error);
setError('获取音乐列表失败,请稍后再试');
setSavedMusicList([]);
} finally {
setIsLoadingMusic(false);
}
};
// 重命名音乐
const renameMusic = async (musicId: string, newName: string) => {
if (!isAuthenticated || !user) return;
try {
const response = await fetch('/api/auth/music/rename', {
method: 'POST',
headers: {
'Content-Type': 'application/json',
},
body: JSON.stringify({
musicId,
name: newName,
username: user.username,
password: sessionPassword || '',
}),
});
if (!response.ok) {
throw new Error('重命名失败');
}
const data = await response.json();
if (data.success) {
setSavedMusicList(prev =>
prev.map(music =>
music.id === parseInt(musicId) ? { ...music, name: newName } : music
)
);
setEditingId(null);
setEditingName('');
console.log('✅ 音乐重命名成功');
} else {
setError(data.error || '重命名失败');
}
} catch (error) {
console.error('❌ 重命名音乐失败:', error);
setError('重命名失败');
}
};
// 删除音乐
const deleteMusic = async (musicId: string) => {
if (!isAuthenticated || !user) return;
if (!confirm('确定要删除这首音乐吗?')) return;
try {
const response = await fetch('/api/auth/music/delete', {
method: 'POST',
headers: {
'Content-Type': 'application/json',
},
body: JSON.stringify({
musicId,
username: user.username,
password: sessionPassword || '',
}),
});
if (!response.ok) {
throw new Error('删除失败');
}
const data = await response.json();
if (data.success) {
setSavedMusicList(prev => prev.filter(music => music.id !== parseInt(musicId)));
console.log('✅ 音乐删除成功');
} else {
setError(data.error || '删除失败');
}
} catch (error) {
console.error('❌ 删除音乐失败:', error);
setError('删除失败');
}
};
// 切换音乐展开状态
const toggleMusicExpansion = (musicId: number) => {
setExpandedMusic(prev => {
const newSet = new Set(prev);
if (newSet.has(musicId)) {
newSet.delete(musicId);
} else {
newSet.add(musicId);
}
return newSet;
});
};
// 播放保存的音乐
const playSavedMusic = async (music: SavedMusic) => {
// 清除当前所有声音选择
unselectAll(true);
// 延迟一下确保清除完成后再开始播放
setTimeout(() => {
// 选择音乐中的所有声音
music.sounds.forEach((soundId: string) => {
// 选择声音
select(soundId);
// 设置音量
const volume = music.volume[soundId] || 50;
setVolume(soundId, volume / 100);
// 设置速度
const speed = music.speed[soundId] || 1;
setSpeed(soundId, speed);
// 设置速率
const rate = music.rate[soundId] || 1;
setRate(soundId, rate);
// 设置随机效果
const randomEffects = music.random_effects[soundId];
if (randomEffects) {
if (randomEffects.volume) {
toggleRandomVolume(soundId);
}
if (randomEffects.speed) {
toggleRandomSpeed(soundId);
}
if (randomEffects.rate) {
toggleRandomRate(soundId);
}
}
});
// 开始播放
play();
console.log('✅ 开始播放音乐:', music.name);
}, 100);
};
// 保存音乐功能
const saveMusic = async () => {
if (!isAuthenticated) {
@ -33,6 +267,12 @@ export function SelectedSoundsDisplay() {
return;
}
if (selectedSoundIds.length === 0) {
setError('请先选择声音');
setTimeout(() => setError(null), 3000);
return;
}
setIsSaving(true);
try {
@ -50,23 +290,15 @@ export function SelectedSoundsDisplay() {
random_effects[sound.id] = sound.isRandomSpeed || sound.isRandomVolume || sound.isRandomRate;
});
// 检查是否有sessionPassword
if (!sessionPassword) {
console.error('会话密码丢失,请重新登录');
setShowLoginPrompt(true);
setIsSaving(false);
return;
}
const musicData = {
name: `我的音乐 ${new Date().toLocaleDateString()}`,
name: musicName || `我的音乐 ${new Date().toLocaleDateString()}`,
sounds: selectedSoundIds,
volume,
speed,
rate,
random_effects,
username: user?.username,
password: sessionPassword // 使用会话密码
password: sessionPassword || '' // 使用会话密码,如果为空则让后端处理
};
// 调用保存API
@ -81,8 +313,9 @@ export function SelectedSoundsDisplay() {
if (response.ok) {
const result = await response.json();
setShowSaveSuccess(true);
setTimeout(() => setShowSaveSuccess(false), 2000);
console.log('✅ 音乐保存成功:', result.music);
// 保存成功后刷新列表
await fetchSavedMusic();
} else {
const errorData = await response.json();
console.error('❌ 保存音乐失败:', errorData.error);
@ -90,11 +323,15 @@ export function SelectedSoundsDisplay() {
if (response.status === 401) {
setShowLoginPrompt(true);
}
setError(errorData.error || '保存失败');
setTimeout(() => setError(null), 3000);
}
} catch (error) {
console.error('❌ 保存音乐失败:', error);
// 网络错误或其他异常,显示登录提示
setShowLoginPrompt(true);
setError('保存失败,请重试');
setTimeout(() => setError(null), 3000);
} finally {
setIsSaving(false);
}
@ -111,6 +348,26 @@ export function SelectedSoundsDisplay() {
.filter(Boolean);
}, [selectedSoundIds, localizedCategories]);
// 当用户认证状态改变时,获取音乐列表
useEffect(() => {
if (isAuthenticated && user) {
fetchSavedMusic();
} else {
setSavedMusicList([]);
}
}, [isAuthenticated, user]);
// 当用户认证状态改变时,获取音乐列表
useEffect(() => {
if (isAuthenticated && user) {
console.log('🎵 用户已登录,自动获取音乐列表...');
fetchSavedMusic();
} else {
setSavedMusicList([]);
}
}, [isAuthenticated, user]);
// 如果没有选中任何声音,不显示组件
if (selectedSounds.length === 0) {
return null;
@ -118,6 +375,23 @@ export function SelectedSoundsDisplay() {
return (
<div className={styles.soundsContainer}>
{/* 音乐名称配置区域 */}
{selectedSounds.length > 0 && (
<div className={styles.musicNameConfig}>
<input
type="text"
value={musicName}
onChange={(e) => setMusicName(e.target.value)}
placeholder="音乐名称"
className={styles.musicNameInput}
maxLength={50}
/>
<SaveMusicButton />
<DeleteMusicButton />
</div>
)}
{/* 选中的声音展示 */}
<div className={styles.sounds}>
<AnimatePresence initial={false}>
{selectedSounds.map((sound) => (
@ -137,24 +411,148 @@ export function SelectedSoundsDisplay() {
</AnimatePresence>
</div>
{/* 保存按钮区域 */}
<div className={styles.saveSection}>
<button
className={`${styles.saveButton} ${isSaving ? styles.saving : ''}`}
onClick={saveMusic}
disabled={isSaving || selectedSounds.length === 0}
title={isAuthenticated ? '保存当前音乐配置' : '请先登录后再保存'}
>
<FaSave />
<span>
{isSaving ? '保存中...' : '保存音乐'}
</span>
</button>
{/* 音乐列表区域 - 只有登录用户才显示 */}
{isAuthenticated && (
<div className={styles.musicSection}>
<div className={styles.musicHeader}>
<h4 className={styles.musicTitle}>
<FaCog className={styles.musicIcon} />
</h4>
</div>
{/* 错误提示 */}
{error && (
<div className={styles.error}>
{error}
<button onClick={() => setError(null)} className={styles.errorClose}>×</button>
</div>
)}
{/* 保存成功提示 */}
{showSaveSuccess && (
<div className={styles.saveSuccess}>
<p> </p>
<button onClick={() => setShowSaveSuccess(false)}>
</button>
</div>
)}
{/* 音乐列表 - 自动显示 */}
<div className={styles.musicList}>
{console.log('🎵 渲染音乐列表:', { isLoadingMusic, listLength: savedMusicList.length })}
{isLoadingMusic ? (
<div className={styles.loading}>...</div>
) : savedMusicList.length === 0 ? (
<div className={styles.empty}>
<FaMusic className={styles.emptyIcon} />
<p></p>
<p className={styles.emptyHint}></p>
</div>
) : (
<AnimatePresence initial={false}>
{savedMusicList.map((music) => (
<div key={music.id} className={styles.musicItem}>
{editingId === music.id.toString() ? (
<div className={styles.editForm}>
<input
type="text"
value={editingName}
onChange={(e) => setEditingName(e.target.value)}
className={styles.editInput}
placeholder="输入音乐名称"
maxLength={50}
/>
<div className={styles.editButtons}>
<button
onClick={() => {
if (editingName.trim()) {
renameMusic(music.id.toString(), editingName.trim());
}
}}
className={`${styles.editButton} ${styles.saveButton}`}
title="保存"
>
</button>
<button
onClick={() => {
setEditingId(null);
setEditingName('');
}}
className={`${styles.editButton} ${styles.cancelButton}`}
title="取消"
>
×
</button>
</div>
</div>
) : (
<div className={styles.musicContent}>
<button
onClick={() => playSavedMusic(music)}
className={styles.playButton}
title="播放这首音乐"
>
<FaPlay />
</button>
<div className={styles.musicInfo}>
<div className={styles.musicNameRow}>
<span
className={styles.musicName}
onClick={() => {
setEditingId(music.id.toString());
setEditingName(music.name);
}}
title="点击编辑名称"
>
{music.name}
</span>
<button
onClick={() => toggleMusicExpansion(music.id)}
className={styles.expandButton}
title="展开/收起声音详情"
>
{expandedMusic.has(music.id) ? '收起 ▲' : '展开 ▼'}
</button>
</div>
{/* 展开时显示收录的声音名字 */}
{expandedMusic.has(music.id) && (
<div className={styles.soundNames}>
{music.sounds && music.sounds.length > 0 ? (
music.sounds.map((soundId: string, index: number) => {
// 从所有声音中查找对应的声音名称
const allSounds = localizedCategories
.map(category => category.sounds)
.flat();
const sound = allSounds.find(s => s.id === soundId);
return sound ? (
<span key={soundId} className={styles.soundName}>
{sound.label}{index < music.sounds.length - 1 ? ', ' : ''}
</span>
) : null;
})
) : (
<span className={styles.noSounds}></span>
)}
</div>
)}
</div>
<button
onClick={() => deleteMusic(music.id.toString())}
className={styles.deleteButton}
title="删除"
>
<FaTrash />
</button>
</div>
)}
</div>
))}
</AnimatePresence>
)}
</div>
</div>
)}
@ -178,6 +576,5 @@ export function SelectedSoundsDisplay() {
</div>
)}
</div>
</div>
);
}

View file

@ -27,13 +27,15 @@
width: 16px;
height: 16px;
cursor: pointer;
background: var(--color-neutral-950);
background: var(--bg-tertiary);
border: 1px solid var(--color-border);
border-radius: 50%;
box-shadow: 0 0 3px var(--color-neutral-50);
}
.sliderThumb:hover {
background: var(--color-neutral-800);
background: var(--bg-secondary);
border-color: var(--color-foreground);
}
.sliderThumb:focus {

View file

@ -74,9 +74,10 @@
height: 14px;
margin-top: -3px;
appearance: none;
background-color: var(--bg-tertiary);
background-color: var(--color-neutral-700);
border: 1px solid var(--color-border);
border-radius: 50%;
box-shadow: 0 0 2px var(--color-neutral-400);
}
&:not(:disabled):focus::-webkit-slider-thumb {
@ -97,11 +98,12 @@
width: 14px;
height: 14px;
margin-top: -3px;
background-color: var(--bg-tertiary);
background-color: var(--color-neutral-700);
border: none;
border: 1px solid var(--color-border);
border-radius: 0;
border-radius: 50%;
box-shadow: 0 0 2px var(--color-neutral-400);
}
&:not(:disabled):focus::-moz-range-thumb {

View file

@ -8,8 +8,12 @@
.soundsContainer {
display: flex;
flex-direction: column;
gap: 20px;
gap: 16px;
margin-top: 20px;
background: var(--bg-secondary);
border-radius: 12px;
border: 1px solid var(--color-border);
padding: 16px;
}
.saveSection {
@ -28,7 +32,7 @@
display: flex;
align-items: center;
gap: 8px;
padding: 12px 20px;
padding: 10px 16px;
background: linear-gradient(135deg, #10b981, #059669);
color: white;
border: none;
@ -37,6 +41,346 @@
font-weight: 500;
cursor: pointer;
transition: all 0.2s ease;
min-height: 40px;
}
/* 音乐管理区域 */
.musicSection {
border-top: 1px solid var(--color-border);
padding-top: 16px;
}
.musicHeader {
display: flex;
align-items: center;
justify-content: space-between;
margin-bottom: 12px;
}
.musicTitle {
display: flex;
align-items: center;
gap: 8px;
margin: 0;
font-size: 16px;
font-weight: 600;
color: var(--color-foreground);
}
.musicIcon {
color: var(--color-foreground-subtle);
font-size: 14px;
}
.toggleMusicList {
background: transparent;
border: 1px solid var(--color-border);
color: var(--color-foreground-subtle);
border-radius: 4px;
padding: 4px 8px;
font-size: 12px;
cursor: pointer;
transition: all 0.2s ease;
}
.toggleMusicList:hover {
background: var(--component-hover);
border-color: var(--color-foreground-subtle);
color: var(--color-foreground);
}
.musicList {
max-height: 300px;
overflow-y: auto;
border-radius: 6px;
background: var(--bg-tertiary);
border: 1px solid var(--color-border);
}
.musicItem {
padding: 12px;
border-bottom: 1px solid var(--color-border);
transition: background-color 0.2s ease;
}
.musicItem:last-child {
border-bottom: none;
}
.musicItem:hover {
background: var(--component-hover);
}
.musicContent {
display: flex;
align-items: center;
gap: 8px;
}
.playButton {
background: var(--color-foreground);
color: var(--bg-primary);
border: none;
border-radius: 4px;
padding: 6px 8px;
cursor: pointer;
font-size: 12px;
transition: all 0.2s ease;
flex-shrink: 0;
}
.playButton:hover {
background: var(--color-foreground-subtle);
}
.musicName {
flex: 1;
font-size: 14px;
color: var(--color-foreground);
cursor: pointer;
padding: 4px 8px;
border-radius: 4px;
transition: background-color 0.2s ease;
min-width: 0;
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
}
.musicName:hover {
background: var(--component-hover);
}
/* 音乐信息容器 */
.musicInfo {
flex: 1;
display: flex;
flex-direction: column;
min-width: 0;
gap: 2px;
}
/* 音乐名称行 */
.musicNameRow {
display: flex;
align-items: center;
justify-content: space-between;
gap: 8px;
}
/* 展开按钮 */
.expandButton {
background: transparent;
border: 1px solid var(--color-border);
color: var(--color-foreground-subtle);
border-radius: 4px;
padding: 2px 6px;
font-size: 10px;
cursor: pointer;
transition: all 0.2s ease;
flex-shrink: 0;
height: 20px;
line-height: 1;
}
.expandButton:hover {
background: var(--component-hover);
border-color: var(--color-foreground-subtle);
color: var(--color-foreground);
}
/* 声音名字显示 */
.soundNames {
font-size: 12px;
color: var(--color-foreground-subtle);
line-height: 1.3;
word-break: break-all;
}
.soundName {
color: var(--color-foreground-subtle);
}
.noSounds {
color: var(--color-foreground-subtler);
font-style: italic;
}
.deleteButton {
background: #ef4444;
color: white;
border: none;
border-radius: 4px;
padding: 6px 8px;
cursor: pointer;
font-size: 12px;
transition: all 0.2s ease;
flex-shrink: 0;
}
.deleteButton:hover {
background: #dc2626;
}
/* 编辑表单样式 */
.editForm {
display: flex;
align-items: center;
gap: 8px;
padding: 4px 0;
}
.editInput {
flex: 1;
padding: 6px 8px;
border: 1px solid var(--color-border);
border-radius: 4px;
font-size: 14px;
background: var(--input-bg);
color: var(--color-foreground);
outline: none;
}
.editInput:focus {
border-color: var(--color-muted);
box-shadow: 0 0 0 2px var(--color-muted);
}
.editButtons {
display: flex;
gap: 4px;
}
.editButton {
border: none;
border-radius: 4px;
padding: 6px 8px;
cursor: pointer;
font-size: 12px;
font-weight: 500;
transition: all 0.2s ease;
width: 28px;
height: 28px;
display: flex;
align-items: center;
justify-content: center;
}
.editButton.saveButton {
background: #10b981;
color: white;
}
.editButton.saveButton:hover {
background: #059669;
}
.editButton.cancelButton {
background: var(--color-muted);
color: var(--color-foreground);
}
.editButton.cancelButton:hover {
background: var(--color-foreground-subtle);
}
/* 音乐名称配置区域 */
.musicNameConfig {
display: flex;
align-items: center;
gap: 12px;
margin-bottom: 16px;
/* 移除边框和背景 */
padding: 0;
background: transparent;
border: none;
border-radius: 0;
}
.musicNameInput {
width: 6.25em; /* 5em * 1.25 = 6.25em增大1/4 */
padding: 8px 12px;
border: 1px solid var(--color-border);
border-radius: 6px;
font-size: 14px;
background: var(--input-bg);
color: var(--color-foreground);
outline: none;
transition: all 0.2s ease;
flex-shrink: 0;
}
.musicNameInput:focus {
border-color: var(--color-muted);
box-shadow: 0 0 0 2px var(--color-muted);
}
.musicNameInput::placeholder {
color: var(--color-foreground-subtler);
}
/* 空状态和加载状态 */
.empty {
padding: 24px;
text-align: center;
color: var(--color-foreground-subtle);
}
.emptyIcon {
font-size: 24px;
color: var(--color-foreground-subtler);
margin-bottom: 8px;
}
.empty p {
margin: 4px 0;
font-size: 14px;
}
.emptyHint {
font-size: 12px !important;
color: var(--color-foreground-subtler) !important;
}
.loading {
padding: 16px;
text-align: center;
color: var(--color-foreground-subtle);
font-size: 14px;
}
/* 错误提示样式 */
.error {
background: rgba(239, 68, 68, 0.1);
border: 1px solid #ef4444;
color: #dc2626;
padding: 8px 12px;
border-radius: 4px;
font-size: 13px;
display: flex;
align-items: center;
justify-content: space-between;
margin-bottom: 12px;
}
.errorClose {
background: none;
border: none;
color: #dc2626;
font-size: 16px;
cursor: pointer;
padding: 0;
width: 20px;
height: 20px;
display: flex;
align-items: center;
justify-content: center;
border-radius: 2px;
transition: background-color 0.2s ease;
}
.errorClose:hover {
background: rgba(239, 68, 68, 0.2);
}
.saveButton:hover:not(:disabled) {
@ -56,13 +400,48 @@
}
.saveSuccess {
position: fixed;
top: 50%;
left: 50%;
transform: translate(-50%, -50%);
padding: 16px;
background: var(--component-bg);
border: 1px solid var(--color-border);
border-radius: 8px;
display: flex;
flex-direction: column;
align-items: center;
gap: 12px;
color: var(--color-foreground);
text-align: center;
box-shadow: 0 4px 6px -1px rgba(0, 0, 0, 0.1);
z-index: 1002;
min-width: 250px;
font-size: 14px;
animation: slideIn 0.3s ease-out;
}
.saveSuccess p {
margin: 0;
font-size: 14px;
color: var(--color-foreground-subtle);
}
.saveSuccess button {
padding: 8px 16px;
background: linear-gradient(135deg, #10b981, #059669);
color: white;
border-radius: 6px;
margin: 0 4px;
border: none;
border-radius: 4px;
font-size: 13px;
font-weight: 500;
animation: slideIn 0.3s ease-out;
cursor: pointer;
transition: all 0.2s ease;
background: var(--color-foreground);
color: var(--bg-primary);
}
.saveSuccess button:hover {
background: var(--color-foreground-subtle);
}
.loginPrompt {

View 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
View 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
View 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);
}

View 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
View 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();
}

View file

@ -1,5 +1,6 @@
import type { APIRoute } from 'astro';
import { authenticateUser } from '@/lib/database';
import { createJWT } from '@/lib/jwt';
export const POST: APIRoute = async ({ request }) => {
try {
@ -32,13 +33,21 @@ export const POST: APIRoute = async ({ request }) => {
});
}
// 创建JWT token
const token = createJWT({
userId: user.id,
username: user.username
});
return new Response(JSON.stringify({
success: true,
user: {
id: user.id,
username: user.username,
created_at: user.created_at
}
},
token,
expiresIn: 7 * 24 * 60 * 60 // 7天单位
}), {
status: 200,
headers: { 'Content-Type': 'application/json' },

View file

@ -1,44 +1,50 @@
import type { APIRoute } from 'astro';
import { deleteMusic } from '@/lib/database';
import { authenticateUser } from '@/lib/database';
import { authenticateJWTRequest, parseRequestBody, handleApiError } from '@/lib/jwt-auth-middleware';
export const POST: APIRoute = async ({ request }) => {
// 首先进行JWT认证
const authResult = await authenticateJWTRequest(request);
if (!authResult.success) {
return new Response(JSON.stringify({ error: authResult.error!.message }), {
status: authResult.error!.status,
headers: { 'Content-Type': 'application/json' }
});
}
// 解析请求体
const bodyResult = await parseRequestBody(request, ['musicId']);
if (!bodyResult.success) {
return new Response(JSON.stringify({ error: bodyResult.error!.message }), {
status: bodyResult.error!.status,
headers: { 'Content-Type': 'application/json' }
});
}
try {
const body = await request.text();
const { user } = authResult;
const { data } = bodyResult;
const { musicId } = data;
if (!body.trim()) {
return new Response(JSON.stringify({ error: '请求体不能为空' }), {
// 验证音乐ID
if (!musicId || (typeof musicId !== 'string' && typeof musicId !== 'number')) {
return new Response(JSON.stringify({
error: '音乐ID不能为空且必须是有效的标识符'
}), {
status: 400,
headers: { 'Content-Type': 'application/json' },
});
}
const { musicId, username, password } = JSON.parse(body);
// 验证输入
if (!musicId || !username || !password) {
return new Response(JSON.stringify({ error: '音乐ID和用户信息不能为空' }), {
status: 400,
headers: { 'Content-Type': 'application/json' },
});
}
// 验证用户身份
const user = authenticateUser(username, password);
if (!user) {
return new Response(JSON.stringify({ error: '用户认证失败' }), {
status: 401,
headers: { 'Content-Type': 'application/json' },
headers: { 'Content-Type': 'application/json' }
});
}
// 删除音乐记录
const success = deleteMusic(musicId, user.id);
const success = deleteMusic(musicId.toString(), user!.id);
if (!success) {
return new Response(JSON.stringify({ error: '音乐不存在或无权限删除' }), {
return new Response(JSON.stringify({
error: '音乐不存在或无权限删除'
}), {
status: 404,
headers: { 'Content-Type': 'application/json' },
headers: { 'Content-Type': 'application/json' }
});
}
@ -47,23 +53,10 @@ export const POST: APIRoute = async ({ request }) => {
message: '音乐删除成功'
}), {
status: 200,
headers: { 'Content-Type': 'application/json' },
headers: { 'Content-Type': 'application/json' }
});
} catch (error) {
console.error('删除音乐错误:', error);
let errorMessage = '删除音乐失败,请稍后再试';
if (error instanceof SyntaxError && error.message.includes('JSON')) {
errorMessage = '请求格式错误';
} else if (error instanceof Error) {
errorMessage = error.message;
}
return new Response(JSON.stringify({ error: errorMessage }), {
status: 500,
headers: { 'Content-Type': 'application/json' },
});
return handleApiError(error, '删除音乐');
}
};

View file

@ -1,42 +1,27 @@
import type { APIRoute } from 'astro';
import { getUserMusic } from '@/lib/database';
import { authenticateUser } from '@/lib/database';
import { authenticateJWTRequest, handleApiError } from '@/lib/jwt-auth-middleware';
export const POST: APIRoute = async ({ request }) => {
// 使用JWT认证中间件
const authResult = await authenticateJWTRequest(request);
if (!authResult.success) {
return new Response(JSON.stringify({ error: authResult.error!.message }), {
status: authResult.error!.status,
headers: { 'Content-Type': 'application/json' }
});
}
try {
const body = await request.text();
if (!body.trim()) {
return new Response(JSON.stringify({ error: '请求体不能为空' }), {
status: 400,
headers: { 'Content-Type': 'application/json' },
});
}
const { username, password } = JSON.parse(body);
// 验证输入
if (!username || !password) {
return new Response(JSON.stringify({ error: '用户名和密码不能为空' }), {
status: 400,
headers: { 'Content-Type': 'application/json' },
});
}
// 验证用户身份
const user = authenticateUser(username, password);
if (!user) {
return new Response(JSON.stringify({ error: '用户认证失败' }), {
status: 401,
headers: { 'Content-Type': 'application/json' },
});
}
const { user } = authResult;
// 获取用户音乐列表
const musicList = getUserMusic(user.id);
const musicList = getUserMusic(user!.id);
// 解析JSON字段
const formattedMusicList = musicList.map(music => ({
// 解析JSON字段并格式化数据
const formattedMusicList = musicList.map(music => {
try {
return {
id: music.id,
name: music.name,
sounds: JSON.parse(music.sounds),
@ -46,30 +31,33 @@ export const POST: APIRoute = async ({ request }) => {
random_effects: JSON.parse(music.random_effects),
created_at: music.created_at,
updated_at: music.updated_at
}));
};
} catch (parseError) {
console.error(`解析音乐数据失败 (ID: ${music.id}):`, parseError);
// 返回安全的默认值
return {
id: music.id,
name: music.name,
sounds: [],
volume: {},
speed: {},
rate: {},
random_effects: {},
created_at: music.created_at,
updated_at: music.updated_at
};
}
});
return new Response(JSON.stringify({
success: true,
musicList: formattedMusicList
}), {
status: 200,
headers: { 'Content-Type': 'application/json' },
headers: { 'Content-Type': 'application/json' }
});
} catch (error) {
console.error('获取音乐列表错误:', error);
let errorMessage = '获取音乐列表失败,请稍后再试';
if (error instanceof SyntaxError && error.message.includes('JSON')) {
errorMessage = '请求格式错误';
} else if (error instanceof Error) {
errorMessage = error.message;
}
return new Response(JSON.stringify({ error: errorMessage }), {
status: 500,
headers: { 'Content-Type': 'application/json' },
});
return handleApiError(error, '获取音乐列表');
}
};

View file

@ -1,44 +1,69 @@
import type { APIRoute } from 'astro';
import { updateMusicName } from '@/lib/database';
import { authenticateUser } from '@/lib/database';
import { authenticateJWTRequest, parseRequestBody, handleApiError } from '@/lib/jwt-auth-middleware';
export const POST: APIRoute = async ({ request }) => {
try {
const body = await request.text();
if (!body.trim()) {
return new Response(JSON.stringify({ error: '请求体不能为空' }), {
status: 400,
headers: { 'Content-Type': 'application/json' },
// 首先进行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 { musicId, name, username, password } = JSON.parse(body);
// 解析请求体
const bodyResult = await parseRequestBody(request, ['musicId', 'name']);
if (!bodyResult.success) {
return new Response(JSON.stringify({ error: bodyResult.error!.message }), {
status: bodyResult.error!.status,
headers: { 'Content-Type': 'application/json' }
});
}
try {
const { user } = authResult;
const { data } = bodyResult;
const { musicId, name } = data;
// 验证输入
if (!musicId || !name || !username || !password) {
return new Response(JSON.stringify({ error: '音乐ID、新名称和用户信息不能为空' }), {
if (!musicId || (typeof musicId !== 'string' && typeof musicId !== 'number')) {
return new Response(JSON.stringify({
error: '音乐ID不能为空且必须是有效的标识符'
}), {
status: 400,
headers: { 'Content-Type': 'application/json' },
headers: { 'Content-Type': 'application/json' }
});
}
// 验证用户身份
const user = authenticateUser(username, password);
if (!user) {
return new Response(JSON.stringify({ error: '用户认证失败' }), {
status: 401,
headers: { 'Content-Type': 'application/json' },
if (!name || typeof name !== 'string' || name.trim().length === 0) {
return new Response(JSON.stringify({
error: '音乐名称不能为空且必须是有效的字符串'
}), {
status: 400,
headers: { 'Content-Type': 'application/json' }
});
}
// 验证名称长度
if (name.trim().length > 100) {
return new Response(JSON.stringify({
error: '音乐名称长度不能超过100个字符'
}), {
status: 400,
headers: { 'Content-Type': 'application/json' }
});
}
// 更新音乐名称
const success = updateMusicName(musicId, name, user.id);
const success = updateMusicName(musicId.toString(), name.trim(), user!.id);
if (!success) {
return new Response(JSON.stringify({ error: '音乐不存在或无权限修改' }), {
return new Response(JSON.stringify({
error: '音乐不存在或无权限修改'
}), {
status: 404,
headers: { 'Content-Type': 'application/json' },
headers: { 'Content-Type': 'application/json' }
});
}
@ -47,23 +72,10 @@ export const POST: APIRoute = async ({ request }) => {
message: '音乐名称更新成功'
}), {
status: 200,
headers: { 'Content-Type': 'application/json' },
headers: { 'Content-Type': 'application/json' }
});
} catch (error) {
console.error('重命名音乐错误:', error);
let errorMessage = '重命名音乐失败,请稍后再试';
if (error instanceof SyntaxError && error.message.includes('JSON')) {
errorMessage = '请求格式错误';
} else if (error instanceof Error) {
errorMessage = error.message;
}
return new Response(JSON.stringify({ error: errorMessage }), {
status: 500,
headers: { 'Content-Type': 'application/json' },
});
return handleApiError(error, '重命名音乐');
}
};

View file

@ -1,40 +1,44 @@
import type { APIRoute } from 'astro';
import { createMusic } from '@/lib/database';
import { authenticateUser } from '@/lib/database';
import { authenticateJWTRequest, parseRequestBody, handleApiError } from '@/lib/jwt-auth-middleware';
export const POST: APIRoute = async ({ request }) => {
try {
const body = await request.text();
if (!body.trim()) {
return new Response(JSON.stringify({ error: '请求体不能为空' }), {
status: 400,
headers: { 'Content-Type': 'application/json' },
// 首先进行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 { name, sounds, volume, speed, rate, random_effects, username, password } = JSON.parse(body);
// 解析请求体
const bodyResult = await parseRequestBody(request, ['name', 'sounds']);
if (!bodyResult.success) {
return new Response(JSON.stringify({ error: bodyResult.error!.message }), {
status: bodyResult.error!.status,
headers: { 'Content-Type': 'application/json' }
});
}
try {
const { user } = authResult;
const { data } = bodyResult;
const { name, sounds, volume, speed, rate, random_effects } = data;
// 验证输入
if (!name || !sounds || !username || !password) {
return new Response(JSON.stringify({ error: '音乐名称、声音配置和用户信息不能为空' }), {
if (!name || !sounds || !Array.isArray(sounds)) {
return new Response(JSON.stringify({
error: '音乐名称和声音配置不能为空,声音必须是数组'
}), {
status: 400,
headers: { 'Content-Type': 'application/json' },
});
}
// 验证用户身份
const user = authenticateUser(username, password);
if (!user) {
return new Response(JSON.stringify({ error: '用户认证失败' }), {
status: 401,
headers: { 'Content-Type': 'application/json' },
headers: { 'Content-Type': 'application/json' }
});
}
// 创建音乐记录
const music = await createMusic({
user_id: user.id,
user_id: user!.id,
name,
sounds,
volume: volume || {},
@ -45,6 +49,7 @@ export const POST: APIRoute = async ({ request }) => {
return new Response(JSON.stringify({
success: true,
message: '音乐保存成功',
music: {
id: music.id,
name: music.name,
@ -52,23 +57,10 @@ export const POST: APIRoute = async ({ request }) => {
}
}), {
status: 201,
headers: { 'Content-Type': 'application/json' },
headers: { 'Content-Type': 'application/json' }
});
} catch (error) {
console.error('保存音乐错误:', error);
let errorMessage = '保存音乐失败,请稍后再试';
if (error instanceof SyntaxError && error.message.includes('JSON')) {
errorMessage = '请求格式错误';
} else if (error instanceof Error) {
errorMessage = error.message;
}
return new Response(JSON.stringify({ error: errorMessage }), {
status: 500,
headers: { 'Content-Type': 'application/json' },
});
return handleApiError(error, '保存音乐');
}
};

View file

@ -1,5 +1,6 @@
import type { APIRoute } from 'astro';
import { createUser } from '@/lib/database';
import { createJWT } from '@/lib/jwt';
export const POST: APIRoute = async ({ request }) => {
try {
@ -39,13 +40,21 @@ export const POST: APIRoute = async ({ request }) => {
// 创建用户
const user = await createUser({ username, password });
// 创建JWT token
const token = createJWT({
userId: user.id,
username: user.username
});
return new Response(JSON.stringify({
success: true,
user: {
id: user.id,
username: user.username,
created_at: user.created_at
}
},
token,
expiresIn: 7 * 24 * 60 * 60 // 7天单位
}), {
status: 201,
headers: { 'Content-Type': 'application/json' },

View file

@ -12,7 +12,7 @@ interface AuthState {
isAuthenticated: boolean;
isLoading: boolean;
error: string | null;
sessionPassword: string | null; // 仅当前会话使用的密码,不持久化
token: string | null; // JWT token
}
interface AuthStore extends AuthState {
@ -23,6 +23,8 @@ interface AuthStore extends AuthState {
clearError: () => void;
setLoading: (loading: boolean) => void;
checkAuth: () => Promise<void>;
getToken: () => string | null;
setToken: (token: string) => void;
}
/**
@ -57,7 +59,7 @@ export const useAuthStore = create<AuthStore>()(
isAuthenticated: false,
isLoading: false,
error: null,
sessionPassword: null,
token: null,
// Actions
login: async (userData) => {
@ -66,20 +68,14 @@ export const useAuthStore = create<AuthStore>()(
try {
const result = await apiCall('/api/auth/login', userData);
const user = result.user;
const token = result.token;
set({
user,
isAuthenticated: true,
isLoading: false,
error: null,
});
set({
user,
isAuthenticated: true,
isLoading: false,
error: null,
sessionPassword: userData.password, // 保存密码用于当前会话的API调用
token,
});
console.log('✅ 用户登录成功:', user.username);
@ -90,7 +86,7 @@ export const useAuthStore = create<AuthStore>()(
isAuthenticated: false,
isLoading: false,
error: errorMessage,
sessionPassword: null,
token: null,
});
console.error('❌ 登录失败:', error);
throw error;
@ -103,20 +99,14 @@ export const useAuthStore = create<AuthStore>()(
try {
const result = await apiCall('/api/auth/register', userData);
const user = result.user;
const token = result.token;
set({
user,
isAuthenticated: true,
isLoading: false,
error: null,
});
set({
user,
isAuthenticated: true,
isLoading: false,
error: null,
sessionPassword: userData.password, // 保存密码用于当前会话的API调用
token,
});
console.log('✅ 用户注册成功:', user.username);
@ -127,7 +117,7 @@ export const useAuthStore = create<AuthStore>()(
isAuthenticated: false,
isLoading: false,
error: errorMessage,
sessionPassword: null,
token: null,
});
console.error('❌ 注册失败:', error);
throw error;
@ -140,7 +130,7 @@ export const useAuthStore = create<AuthStore>()(
isAuthenticated: false,
isLoading: false,
error: null,
sessionPassword: null, // 清除会话密码
token: null,
});
console.log('✅ 用户已登出');
},
@ -154,37 +144,33 @@ export const useAuthStore = create<AuthStore>()(
},
checkAuth: async () => {
const { user, isAuthenticated } = get();
const { user, isAuthenticated, token } = get();
// 如果已经有用户信息且已认证,则直接返回
if (user && isAuthenticated) {
if (user && isAuthenticated && token) {
return;
}
// 这里可以添加token验证或会话验证逻辑
// 目前简单检查本地存储的用户信息
set({ isLoading: true });
// zustand persist会自动从localStorage恢复token
// 如果有token但没有用户信息说明token可能无效
if (token && !user) {
console.warn('发现token但缺少用户信息可能需要重新登录');
set({
token: null,
isAuthenticated: false
});
}
try {
// 如果有用户信息但未认证,可以尝试验证
if (user && !isAuthenticated) {
set({
isAuthenticated: true,
isLoading: false,
error: null,
});
} else {
set({ isLoading: false });
}
} catch (error) {
console.error('❌ 认证检查失败:', error);
set({
user: null,
isAuthenticated: false,
isLoading: false,
error: '认证检查失败',
});
}
},
getToken: () => {
const { token } = get();
return token;
},
setToken: (newToken: string) => {
set({ token: newToken });
},
}),
{
@ -192,7 +178,7 @@ export const useAuthStore = create<AuthStore>()(
partialize: (state) => ({
user: state.user,
isAuthenticated: state.isAuthenticated,
// 不包含 sessionPassword仅存储在内存中
token: state.token, // 现在也保存token
}),
}
)

71
test-jwt.js Normal file
View 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
View file