feat: 重构音乐列表UI布局与模块分离优化

- 重命名"当前选中音乐"为"当前选中的声音"
- 完全分离两个模块为独立UI组件,移除互斥展开逻辑
- 优化音乐列表横向布局:音乐名称、声音名称和按钮在同一行显示
- 实现智能展开逻辑:音乐列表默认展开,超过5项时自动收起
- 增加模块间32px间距,提升视觉层次
- 修复展开按钮样式冲突,优化CSS类名结构
- 改进组件状态管理,确保模块独立性
This commit is contained in:
walle 2025-11-18 17:10:38 +08:00
parent b477733188
commit a76585c61a
5 changed files with 219 additions and 150 deletions

View file

@ -14,7 +14,8 @@ interface LanguageSwitcherProps {
export function LanguageSwitcher({ className }: LanguageSwitcherProps) {
const { currentLang, changeLanguage, t } = useTranslation();
const { isAuthenticated, user, login, register, logout, isLoading, checkAuth, error, clearError } = useAuthStore();
const [isDarkTheme, setIsDarkTheme] = useState<boolean>(false);
const [isDarkTheme, setIsDarkTheme] = useState<boolean | null>(null); // 使用 null 表示未初始化
const [isClient, setIsClient] = useState(false); // 跟踪是否在客户端
const [showAuthForm, setShowAuthForm] = useState(false);
const [isLogin, setIsLogin] = useState(true);
const [showNotification, setShowNotification] = useState(false);
@ -26,10 +27,17 @@ export function LanguageSwitcher({ className }: LanguageSwitcherProps) {
password: '',
});
// 客户端检测
useEffect(() => {
setIsClient(true);
}, []);
// 认证状态检查
useEffect(() => {
checkAuth();
}, []);
if (isClient) {
checkAuth();
}
}, [isClient]);
// 点击外部关闭用户菜单
useEffect(() => {
@ -63,8 +71,13 @@ export function LanguageSwitcher({ className }: LanguageSwitcherProps) {
};
}, []);
// 主题切换逻辑
// 主题切换逻辑 - 确保只在客户端执行
useEffect(() => {
// 避免在 SSR 环境下执行
if (typeof window === 'undefined' || typeof localStorage === 'undefined') {
return;
}
const savedTheme = localStorage.getItem('theme');
const systemPrefersDark = window.matchMedia('(prefers-color-scheme: dark)').matches;
const initialDarkTheme = savedTheme ? savedTheme === 'dark' : systemPrefersDark;
@ -114,6 +127,11 @@ export function LanguageSwitcher({ className }: LanguageSwitcherProps) {
};
const toggleTheme = () => {
// 确保主题已初始化且在客户端环境
if (isDarkTheme === null || typeof window === 'undefined' || typeof localStorage === 'undefined') {
return;
}
const newTheme = !isDarkTheme;
setIsDarkTheme(newTheme);
applyTheme(newTheme);
@ -203,23 +221,26 @@ export function LanguageSwitcher({ className }: LanguageSwitcherProps) {
<button
className={styles.controlButton}
onClick={toggleTheme}
aria-label={isDarkTheme ? 'Switch to light mode' : 'Switch to dark mode'}
aria-label={isDarkTheme === true ? 'Switch to light mode' : isDarkTheme === false ? 'Switch to dark mode' : 'Loading theme'}
disabled={isDarkTheme === null}
>
<AnimatePresence initial={false} mode="wait">
<motion.span
animate="show"
aria-hidden="true"
exit="hidden"
initial="hidden"
key={isDarkTheme ? 'moon' : 'sun'}
variants={variants}
style={{ display: 'flex', alignItems: 'center' }}
>
{isDarkTheme ? <FaMoon /> : <FaSun />}
</motion.span>
</AnimatePresence>
{isDarkTheme !== null && (
<AnimatePresence initial={false} mode="wait">
<motion.span
animate="show"
aria-hidden="true"
exit="hidden"
initial="hidden"
key={isDarkTheme ? 'moon' : 'sun'}
variants={variants}
style={{ display: 'flex', alignItems: 'center' }}
>
{isDarkTheme ? <FaMoon /> : <FaSun />}
</motion.span>
</AnimatePresence>
)}
<span style={{ marginLeft: '8px', fontSize: '14px' }}>
{isDarkTheme ? '黑暗' : '明亮'}
{isDarkTheme === null ? '...' : (isDarkTheme ? '黑暗' : '明亮')}
</span>
</button>
@ -229,10 +250,10 @@ export function LanguageSwitcher({ className }: LanguageSwitcherProps) {
onClick={handleAuthClick}
whileHover={{ scale: 1.02 }}
whileTap={{ scale: 0.98 }}
aria-label={isAuthenticated ? `用户: ${user?.username}` : '登录'}
aria-label={isClient ? (isAuthenticated ? `用户: ${user?.username}` : '登录') : '登录'}
>
<FaUser />
{isAuthenticated && user && (
{isClient && isAuthenticated && user && (
<span className={styles.userIndicator}></span>
)}
<span style={{
@ -243,7 +264,7 @@ export function LanguageSwitcher({ className }: LanguageSwitcherProps) {
textOverflow: 'ellipsis',
whiteSpace: 'nowrap'
}}>
{isAuthenticated ? (user?.username || '用户') : '登录'}
{isClient ? (isAuthenticated ? (user?.username || '用户') : '登录') : '登录'}
</span>
</motion.button>
</div>

View file

@ -37,8 +37,8 @@ export function SelectedSoundsDisplay() {
const [editingId, setEditingId] = useState<string | null>(null);
const [editingName, setEditingName] = useState('');
const [expandedMusic, setExpandedMusic] = useState<Set<number>>(new Set()); // 跟踪展开的音乐项
const [expandedCurrent, setExpandedCurrent] = useState(true); // 跟踪当前选中的展开状态,默认展开
const [expandedMyMusic, setExpandedMyMusic] = useState(false); // 跟踪我的音乐展开状态,默认收起
const [expandedCurrent, setExpandedCurrent] = useState(true); // 跟踪当前选中音的展开状态,默认展开
const [expandedMyMusic, setExpandedMyMusic] = useState(true); // 跟踪音乐列表展开状态,默认展开
const [error, setError] = useState<string | null>(null);
const [musicName, setMusicName] = useState('');
@ -50,24 +50,18 @@ export function SelectedSoundsDisplay() {
Object.keys(state.sounds).filter(id => state.sounds[id].isSelected)
);
// 互斥展开逻辑:展开当前音乐时收起我的音乐,反之亦然
// 独立展开逻辑:两个区域可以独立展开/收起
const toggleExpandedCurrent = () => {
if (expandedCurrent) {
setExpandedCurrent(false);
} else {
setExpandedCurrent(true);
setExpandedMyMusic(false);
setExpandedMusic(new Set()); // 收起所有展开的音乐项
setExpandedCurrent(!expandedCurrent);
if (!expandedCurrent) {
setExpandedMusic(new Set()); // 展开时收起所有展开的音乐项
}
};
const toggleExpandedMyMusic = () => {
if (expandedMyMusic) {
setExpandedMyMusic(false);
} else {
setExpandedMyMusic(true);
setExpandedCurrent(false);
setExpandedMusic(new Set()); // 收起所有展开的音乐项
setExpandedMyMusic(!expandedMyMusic);
if (!expandedMyMusic) {
setExpandedMusic(new Set()); // 展开时收起所有展开的音乐项
}
};
@ -118,13 +112,21 @@ export function SelectedSoundsDisplay() {
// 根据选中的声音ID获取声音对象
const selectedSounds = useMemo(() => {
return selectedSoundIds.map(id => {
const sound = sounds[id];
// 从 localizedCategories 中查找对应的声音数据
const allSounds = localizedCategories
.map(category => category.sounds)
.flat();
const soundData = allSounds.find(s => s.id === id);
if (!soundData) return null;
return {
id,
...sound
...soundData,
...sounds[id] // 合并状态信息volume, speed 等)
};
}).filter(Boolean);
}, [selectedSoundIds, sounds]);
}, [selectedSoundIds, sounds, localizedCategories]);
// 获取音乐列表
const fetchMusicList = async () => {
@ -244,19 +246,28 @@ export function SelectedSoundsDisplay() {
}
}, [isAuthenticated, user]);
// 监听音乐列表数量超过5个时默认收起
useEffect(() => {
if (savedMusicList.length > 5) {
setExpandedMyMusic(false);
} else {
setExpandedMyMusic(true);
}
}, [savedMusicList.length]);
// 如果没有选中的声音,不渲染组件
if (selectedSounds.length === 0) {
return null;
}
return (
<div className={styles.soundsContainer}>
{/* 当前选中音乐标题区域 */}
{selectedSounds.length > 0 && (
<div className={styles.musicHeader}>
<h4 className={styles.musicTitle}>
<div className={styles.container}>
{/* 当前选中声音模块 */}
<div className={styles.currentSoundsModule}>
<div className={styles.currentSoundsHeader}>
<h4 className={styles.currentSoundsTitle}>
<FaMusic className={styles.musicIcon} />
</h4>
<button
className={`${styles.expandButton} ${styles.expandButtonCurrent}`}
@ -266,53 +277,53 @@ export function SelectedSoundsDisplay() {
{expandedCurrent ? <FaChevronDown /> : <FaChevronRight />}
</button>
</div>
)}
{/* 音乐名称配置区域 */}
{selectedSounds.length > 0 && expandedCurrent && (
<div className={styles.musicNameConfig}>
<input
type="text"
value={musicName}
onChange={(e) => setMusicName(e.target.value)}
placeholder="音乐名称"
className={styles.musicNameInput}
maxLength={50}
/>
<SaveMusicButton />
<DeleteMusicButton />
</div>
)}
{/* 音乐名称配置区域 */}
{expandedCurrent && (
<div className={styles.musicNameConfig}>
<input
type="text"
value={musicName}
onChange={(e) => setMusicName(e.target.value)}
placeholder="音乐名称"
className={styles.musicNameInput}
maxLength={50}
/>
<SaveMusicButton />
<DeleteMusicButton />
</div>
)}
{/* 选中的声音展示 */}
{selectedSounds.length > 0 && expandedCurrent && (
<div className={styles.sounds}>
<AnimatePresence initial={false}>
{selectedSounds.map((sound) => (
<Sound
key={sound.id}
id={sound.id}
icon={sound.icon}
label={sound.label}
src={sound.src}
functional={false}
displayMode={true}
hidden={false}
selectHidden={() => {}}
unselectHidden={() => {}}
/>
))}
</AnimatePresence>
</div>
)}
{/* 选中的声音展示 */}
{expandedCurrent && (
<div className={styles.sounds}>
<AnimatePresence initial={false}>
{selectedSounds.map((sound) => (
<Sound
key={sound.id}
id={sound.id}
icon={sound.icon}
label={sound.label}
src={sound.src}
functional={false}
displayMode={true}
hidden={false}
selectHidden={() => {}}
unselectHidden={() => {}}
/>
))}
</AnimatePresence>
</div>
)}
</div>
{/* 音乐列表区域 - 只有登录用户才显示 */}
{isAuthenticated && (
<div className={styles.musicSection}>
{/* 音乐列表模块 - 只有登录用户且有音乐时才显示 */}
{isAuthenticated && savedMusicList.length > 0 && (
<div className={`${styles.musicListModule} ${styles.musicSection}`}>
<div className={styles.musicHeader}>
<h4 className={styles.musicTitle}>
<FaCog className={styles.musicIcon} />
</h4>
<button
className={styles.expandButton}
@ -390,43 +401,43 @@ export function SelectedSoundsDisplay() {
</div>
) : (
<div className={styles.musicContent}>
<div className={styles.musicNameRow}>
<div className={styles.musicInfo}>
<div className={styles.musicName}>{music.name}</div>
<div className={styles.soundNames}>
{music.sounds && music.sounds.length > 0 ? (
music.sounds.map((soundId, index) => {
// 从所有声音中查找对应的声音名称
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 className={styles.musicInfo}>
<div className={styles.musicName}>{music.name}</div>
<div className={styles.soundNames}>
{music.sounds && music.sounds.length > 0 ? (
music.sounds.map((soundId, index) => {
// 从所有声音中查找对应的声音名称
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>
<button
onClick={() => toggleMusicExpansion(music.id)}
className={styles.expandButton}
title="展开/收起声音详情"
>
{expandedMusic.has(music.id) ? '收起 ▲' : '展开 ▼'}
</button>
<div className={styles.musicActions}>
<button
onClick={() => deleteMusic(music.id.toString())}
className={styles.deleteButton}
title="删除"
>
<FaTrash />
</button>
<button
onClick={() => toggleMusicExpansion(music.id)}
className={styles.expandButton}
title="展开/收起声音详情"
>
{expandedMusic.has(music.id) ? '收起 ▲' : '展开 ▼'}
</button>
</div>
</div>
)}
@ -434,7 +445,7 @@ export function SelectedSoundsDisplay() {
{expandedMusic.has(music.id) && (
<div className={styles.expandedMusicContent}>
{/* 播放按钮 */}
<div className={styles.musicActions}>
<div className={styles.expandedMusicActions}>
<button
onClick={() => playMusicRecord(music)}
className={styles.playMusicButton}

View file

@ -54,9 +54,10 @@ export const Sound = forwardRef<HTMLDivElement, SoundProps>(function Sound(
[speed, rate],
);
const isLoading = useLoadingStore(state => state.loaders[src]);
const isLoading = src ? useLoadingStore(state => state.loaders[src]) : false;
const sound = useSound(src, { loop: true, volume: adjustedVolume, speed: actualPlaybackRate });
// 确保 src 存在才创建声音实例
const sound = useSound(src || '', { loop: true, volume: adjustedVolume, speed: actualPlaybackRate });
useEffect(() => {
if (locked) return;

View file

@ -5,11 +5,34 @@
margin-top: 20px;
}
/* 主容器 */
.container {
display: flex;
flex-direction: column;
gap: 32px; /* 两个模块之间的间距 */
margin-top: 20px;
}
/* 当前选中声音模块 */
.currentSoundsModule {
background: var(--bg-secondary);
border-radius: 12px;
padding: 20px;
border: 1px solid var(--color-border);
}
/* 音乐列表模块 */
.musicListModule {
background: var(--bg-secondary);
border-radius: 12px;
padding: 20px;
border: 1px solid var(--color-border);
}
.soundsContainer {
display: flex;
flex-direction: column;
gap: 16px;
margin-top: 20px;
background: var(--bg-secondary);
border-radius: 12px;
padding: 16px;
@ -43,12 +66,30 @@
min-height: 40px;
}
/* 音乐管理区域 */
/* 音乐列表区域基础样式 */
.musicSection {
border-top: 1px solid var(--color-border);
padding-top: 16px;
/* 继承模块样式,不需要额外样式 */
}
/* 当前选中声音标题样式 */
.currentSoundsHeader {
display: flex;
align-items: center;
justify-content: space-between;
margin-bottom: 16px;
}
.currentSoundsTitle {
display: flex;
align-items: center;
gap: 8px;
margin: 0;
font-size: 16px;
font-weight: 600;
color: var(--color-foreground);
}
/* 音乐列表标题样式 */
.musicHeader {
display: flex;
align-items: center;
@ -109,10 +150,13 @@
background: var(--component-hover);
}
/* 音乐内容容器 - 新的横向布局 */
.musicContent {
display: flex;
align-items: flex-start;
align-items: center;
justify-content: space-between;
gap: 12px;
padding: 8px 0;
}
.playButton {
@ -131,40 +175,32 @@
background: var(--color-foreground-subtle);
}
/* 音乐名称 */
.musicName {
flex: 1;
font-size: 14px;
font-weight: 500;
color: var(--color-foreground);
cursor: pointer;
padding: 4px 8px;
border-radius: 4px;
transition: background-color 0.2s ease;
min-width: 0;
line-height: 1.2;
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
}
.musicName:hover {
background: var(--component-hover);
}
/* 音乐信息容器 */
/* 音乐信息容器 - 左侧信息 */
.musicInfo {
flex: 1;
min-width: 0;
display: flex;
flex-direction: column;
min-width: 0;
gap: 2px;
}
/* 音乐名称行 */
.musicNameRow {
/* 音乐操作按钮容器 - 右侧按钮 */
.musicActions {
display: flex;
align-items: center;
justify-content: space-between;
gap: 12px;
flex-wrap: wrap;
gap: 6px;
flex-shrink: 0;
}
/* 声音名字显示 */
@ -552,7 +588,7 @@
border: 1px solid var(--color-border);
}
.musicActions {
.expandedMusicActions {
display: flex;
justify-content: center;
margin-bottom: 16px;

View file

@ -38,7 +38,7 @@ export function useSound(
const sound = useMemo<Howl | null>(() => {
let sound: Howl | null = null;
if (isBrowser) {
if (isBrowser && src) {
sound = new Howl({
html5,
onload: () => {
@ -46,7 +46,7 @@ export function useSound(
setHasLoaded(true);
},
preload: options.preload ?? false,
src: src,
src: [src], // Howler.js 期望 src 是数组格式
});
}