feat: 升级到 v3.0.0 - 独立音乐播放系统与 Docker 数据持久化

🎉 版本升级: 2.5.0 → 3.0.0

🎵 音乐播放系统重构:
- 独立的音乐播放系统,不影响当前选中声音
- 修复 React Hooks 调用错误
- 使用直接 Howl API 实现音频控制
- 添加播放/停止状态视觉反馈
- 组件显示逻辑完全分离

🐳 Docker 部署优化:
- 所有 compose 文件添加 SQLite 数据库挂载
- 支持 WAL 模式和并发写入
- 数据持久化,容器重启不丢失数据
- 创建详细的 Docker 数据库挂载文档

🎨 UI/UX 改进:
- 修复当前选中声音与音乐列表显示互斥问题
- 播放按钮状态动态显示
- 组件模块完全独立展示

🗄️ 数据库性能优化:
- 启用 WAL 模式提高并发性能
- 优化 SQLite 配置参数
- 添加详细日志和错误处理

📦 新增文件:
- docker-database-mount.md: Docker 数据库挂载说明文档
This commit is contained in:
walle 2025-11-19 15:21:49 +08:00
parent 26f6619904
commit a3c95ec19b
12 changed files with 350 additions and 88 deletions

Binary file not shown.

BIN
data/users.db-shm Normal file

Binary file not shown.

BIN
data/users.db-wal Normal file

Binary file not shown.

View file

@ -31,6 +31,8 @@ services:
- .:/app
- /app/node_modules # 防止node_modules被覆盖
- moodist-dist:/app/dist
# 挂载 SQLite 数据库文件目录
- ./data:/app/data:rw
# 工作目录
working_dir: /app

View file

@ -51,7 +51,14 @@ services:
security_opt:
- no-new-privileges:true
# 只读根文件系统
# 数据卷挂载
volumes:
# 挂载 SQLite 数据库文件目录(需要读写权限)
- ./data:/app/data:rw
# 挂载临时目录用于 SQLite WAL 文件
- moodist-temp:/tmp:rw
# 只读根文件系统(除了数据目录)
read_only: true
tmpfs:
- /var/cache/nginx
@ -76,6 +83,10 @@ services:
profiles:
- proxy
volumes:
moodist-temp:
driver: local
networks:
moodist-network:
driver: bridge

View file

@ -8,3 +8,8 @@ services:
restart: always
ports:
- '8080:8080'
volumes:
# 挂载 SQLite 数据库文件和 WAL 文件
- ./data:/app/data:rw
environment:
- NODE_ENV=production

113
docker-database-mount.md Normal file
View file

@ -0,0 +1,113 @@
# Docker 数据库挂载说明
## 概述
本项目已配置 SQLite 数据库文件挂载,确保数据在容器重启后不会丢失。
## 数据库文件位置
SQLite 数据库文件位于项目的 `./data` 目录中:
- `./data/users.db` - 主数据库文件
- `./data/users.db-wal` - Write-Ahead Log 文件
- `./data/users.db-shm` - 共享内存文件
## Docker Compose 配置
### 1. 基础配置 (`docker-compose.yml`)
```yaml
services:
moodist:
volumes:
# 挂载 SQLite 数据库文件和 WAL 文件
- ./data:/app/data:rw
environment:
- NODE_ENV=production
```
### 2. 优化配置 (`docker-compose.optimized.yml`)
```yaml
services:
moodist:
volumes:
# 挂载 SQLite 数据库文件目录(需要读写权限)
- ./data:/app/data:rw
# 挂载临时目录用于 SQLite WAL 文件
- moodist-temp:/tmp:rw
volumes:
moodist-temp:
driver: local
```
### 3. 开发配置 (`docker-compose.dev.yml`)
```yaml
services:
moodist-dev:
volumes:
# 挂载 SQLite 数据库文件目录
- ./data:/app/data:rw
```
## 使用方法
### 启动服务
```bash
# 生产环境
docker-compose up -d
# 优化环境
docker-compose -f docker-compose.optimized.yml up -d
# 开发环境
docker-compose -f docker-compose.dev.yml up -d
```
### 数据持久化
- 数据库文件会自动创建在 `./data` 目录中
- 容器重启或重新创建后数据不会丢失
- 支持数据库备份和迁移
### 备份数据库
```bash
# 备份数据库
cp ./data/users.db ./data/users.db.backup.$(date +%Y%m%d_%H%M%S)
# 查看数据库文件
ls -la ./data/
```
## 注意事项
1. **权限问题**: 确保 `./data` 目录有正确的读写权限
2. **WAL 模式**: SQLite 使用 WAL (Write-Ahead Logging) 模式,会产生额外的 WAL 和 SHM 文件
3. **并发访问**: Docker 挂载确保文件系统的一致性
4. **备份策略**: 建议定期备份数据库文件
## 故障排除
### 数据库锁定问题
如果遇到数据库锁定错误:
1. 停止容器:`docker-compose down`
2. 删除 WAL 文件:`rm ./data/users.db-wal ./data/users.db-shm`
3. 重新启动容器:`docker-compose up -d`
### 权限问题
如果遇到权限错误:
```bash
# 设置正确的目录权限
sudo chown -R 1000:1000 ./data
chmod 755 ./data
```
## 开发环境注意事项
开发环境中,数据库文件会被实时同步到本地文件系统,便于:
- 调试和测试
- 数据分析
- 快速重置测试数据

View file

@ -1,7 +1,7 @@
{
"name": "moodist",
"type": "module",
"version": "2.5.0",
"version": "3.0.0",
"scripts": {
"dev": "astro dev",
"start": "astro dev",

View file

@ -1,6 +1,6 @@
import { useMemo, useState, useEffect } from 'react';
import { useMemo, useState, useEffect, useRef } from 'react';
import { AnimatePresence, motion } from 'motion/react';
import { FaSave, FaPlay, FaTrash, FaEdit, FaCog, FaSignOutAlt, FaMusic, FaChevronDown, FaChevronRight } from 'react-icons/fa/index';
import { FaSave, FaPlay, FaStop, FaTrash, FaEdit, FaCog, FaSignOutAlt, FaMusic, FaChevronDown, FaChevronRight } from 'react-icons/fa/index';
import { SaveMusicButton } from '@/components/buttons/save-music/save-music';
import { DeleteMusicButton } from '@/components/buttons/delete-music/delete-music';
@ -9,6 +9,7 @@ import { useLocalizedSounds } from '@/hooks/useLocalizedSounds';
import { useTranslation } from '@/hooks/useTranslation';
import { useAuthStore } from '@/stores/auth';
import { ApiClient } from '@/lib/api-client';
import { Howl } from 'howler';
import { Sound } from '@/components/sounds/sound';
import styles from '../sounds/sounds.module.css';
@ -42,6 +43,11 @@ export function SelectedSoundsDisplay() {
const [error, setError] = useState<string | null>(null);
const [musicName, setMusicName] = useState('');
// 独立的音乐播放状态
const [currentlyPlayingMusic, setCurrentlyPlayingMusic] = useState<SavedMusic | null>(null);
const musicHowlInstances = useRef<Record<string, Howl>>({});
const [isPlayingMusic, setIsPlayingMusic] = useState(false);
// 获取声音store
const sounds = useSoundStore(state => state.sounds);
@ -65,34 +71,112 @@ export function SelectedSoundsDisplay() {
}
};
// 获取声音store的操作函数
const unselectAll = useSoundStore(state => state.unselectAll);
const select = useSoundStore(state => state.select);
// 获取声音store的操作函数(仅用于控制主要播放状态)
const play = useSoundStore(state => state.play);
const pause = useSoundStore(state => state.pause);
// 播放音乐记录 - 清空当前选择并加载音乐的声音配置
// 停止音乐播放
const stopMusic = () => {
console.log('🛑 停止音乐播放');
// 停止所有音乐相关的 Howl 实例
Object.values(musicHowlInstances.current).forEach(howlInstance => {
if (howlInstance) {
howlInstance.stop();
howlInstance.unload();
}
});
musicHowlInstances.current = {};
setCurrentlyPlayingMusic(null);
setIsPlayingMusic(false);
};
// 播放音乐记录 - 使用独立的音乐播放系统,不影响当前选中声音
const playMusicRecord = async (music: SavedMusic) => {
try {
// 清空当前所有选择
unselectAll();
console.log('🎵 开始播放音乐:', music.name);
console.log('🎵 音乐数据:', {
sounds: music.sounds,
volume: music.volume,
speed: music.speed,
rate: music.rate,
random_effects: music.random_effects
});
// 根据音乐记录重新选择声音并设置参数
for (const [soundId, volume] of Object.entries(music.volume)) {
const speed = music.speed[soundId] || 1;
// 先停止当前播放的音乐
stopMusic();
// 停止主要的选中声音播放(但不改变选中状态)
pause();
// 获取所有声音数据
const allSounds = localizedCategories
.map(category => category.sounds)
.flat();
// 创建所有声音的 Howl 实例
const howlPromises: Promise<Howl>[] = [];
for (const soundId of music.sounds) {
const soundData = allSounds.find(s => s.id === soundId);
if (!soundData || !soundData.src) continue;
const volume = music.volume[soundId] || 0.5;
const rate = music.rate[soundId] || 1;
const randomEffect = music.random_effects[soundId] || false;
const speed = music.speed[soundId] || 1;
// 选择声音并设置参数
select(soundId, {
volume,
speed,
rate,
randomEffect
console.log(`🔊 创建音乐声音: ${soundId}`, { volume, rate, speed });
// 创建 Howl 实例的 Promise
const howlPromise = new Promise<Howl>((resolve, reject) => {
const howl = new Howl({
src: [soundData.src],
loop: true,
volume: volume,
rate: rate,
preload: true,
onload: () => {
console.log(`✅ 声音加载完成: ${soundId}`);
resolve(howl);
},
onloaderror: (id, error) => {
console.error(`❌ 声音加载失败: ${soundId}`, error);
reject(error);
}
});
// 保存实例引用
musicHowlInstances.current[soundId] = howl;
});
howlPromises.push(howlPromise);
}
console.log(`🎵 播放音乐记录: ${music.name}`);
// 等待所有声音加载完成
console.log('⏳ 等待所有声音加载...');
await Promise.all(howlPromises);
console.log('✅ 所有声音加载完成,开始播放');
// 播放所有声音
Object.values(musicHowlInstances.current).forEach(howlInstance => {
if (howlInstance && howlInstance.state() === 'loaded') {
howlInstance.play();
}
});
// 设置播放状态
setCurrentlyPlayingMusic(music);
setIsPlayingMusic(true);
// 展开对应的音乐记录
setExpandedMusic(new Set([music.id]));
setExpandedCurrent(false); // 收起当前选中声音模块
console.log(`✅ 播放音乐记录完成: ${music.name}`);
} catch (error) {
console.error('❌ 播放音乐记录失败:', error);
stopMusic();
}
};
@ -253,6 +337,13 @@ export function SelectedSoundsDisplay() {
}
}, [isAuthenticated, user]);
// 组件卸载时清理音乐播放
useEffect(() => {
return () => {
stopMusic();
};
}, []);
// 监听音乐列表数量超过5个时默认收起
useEffect(() => {
if (savedMusicList.length > 5) {
@ -262,66 +353,68 @@ export function SelectedSoundsDisplay() {
}
}, [savedMusicList.length]);
// 如果没有选中声音,不渲染组件
if (selectedSounds.length === 0) {
// 如果没有选中声音,也没有音乐列表,则不渲染组件
if (selectedSounds.length === 0 && (!isAuthenticated || savedMusicList.length === 0)) {
return null;
}
return (
<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}`}
onClick={toggleExpandedCurrent}
title={expandedCurrent ? "收起" : "展开"}
>
{expandedCurrent ? <FaChevronDown /> : <FaChevronRight />}
</button>
{/* 当前选中声音模块 - 只有选中声音时才显示 */}
{selectedSounds.length > 0 && (
<div className={styles.currentSoundsModule}>
<div className={styles.currentSoundsHeader}>
<h4 className={styles.currentSoundsTitle}>
<FaMusic className={styles.musicIcon} />
</h4>
<button
className={`${styles.expandButton} ${styles.expandButtonCurrent}`}
onClick={toggleExpandedCurrent}
title={expandedCurrent ? "收起" : "展开"}
>
{expandedCurrent ? <FaChevronDown /> : <FaChevronRight />}
</button>
</div>
{/* 音乐名称配置区域 */}
{expandedCurrent && (
<div className={styles.musicNameConfig}>
<input
type="text"
value={musicName}
onChange={(e) => setMusicName(e.target.value)}
placeholder="音乐名称"
className={styles.musicNameInput}
maxLength={50}
/>
<SaveMusicButton />
</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>
{/* 音乐名称配置区域 */}
{expandedCurrent && (
<div className={styles.musicNameConfig}>
<input
type="text"
value={musicName}
onChange={(e) => setMusicName(e.target.value)}
placeholder="音乐名称"
className={styles.musicNameInput}
maxLength={50}
/>
<SaveMusicButton />
</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 && savedMusicList.length > 0 && (
@ -400,6 +493,21 @@ export function SelectedSoundsDisplay() {
</div>
) : (
<div className={styles.musicContent}>
<button
onClick={() => {
if (currentlyPlayingMusic?.id === music.id) {
stopMusic();
} else {
playMusicRecord(music);
}
}}
className={`${styles.playButton} ${
currentlyPlayingMusic?.id === music.id ? styles.playing : ''
}`}
title={currentlyPlayingMusic?.id === music.id ? "停止播放" : "播放这首音乐"}
>
{currentlyPlayingMusic?.id === music.id ? <FaStop /> : <FaPlay />}
</button>
<div className={styles.musicInfo}>
<div className={styles.musicName}>{music.name}</div>
<div className={styles.soundNames}>
@ -443,18 +551,6 @@ export function SelectedSoundsDisplay() {
{/* 展开时显示的声音内容 */}
{expandedMusic.has(music.id) && (
<div className={styles.expandedMusicContent}>
{/* 播放按钮 */}
<div className={styles.expandedMusicActions}>
<button
onClick={() => playMusicRecord(music)}
className={styles.playMusicButton}
title="播放这首音乐"
>
<FaPlay />
</button>
</div>
{/* 声音组件展示 */}
<div className={styles.sounds}>
<AnimatePresence initial={false}>

View file

@ -181,6 +181,15 @@
background: var(--color-foreground-subtle);
}
.playButton.playing {
background: #e74c3c;
color: white;
}
.playButton.playing:hover {
background: #c0392b;
}
/* 音乐名称 */
.musicName {
font-size: 14px;

View file

@ -51,7 +51,17 @@ export function getDatabase(): Database.Database {
fs.mkdirSync(dbDir, { recursive: true });
}
db = new Database(dbPath);
// 以读写模式打开数据库启用WAL模式提高并发性能
db = new Database(dbPath, {
readonly: false,
fileMustExist: false,
verbose: console.log // 添加SQL执行日志
});
// 启用WAL模式提高并发写入性能
db.pragma('journal_mode = WAL');
db.pragma('synchronous = NORMAL');
db.pragma('cache_size = 1000');
// 创建用户表
db.exec(`

View file

@ -26,6 +26,8 @@ export const POST: APIRoute = async ({ request }) => {
const { data } = bodyResult;
const { name, sounds, volume, speed, rate, random_effects } = data;
console.log('🎵 保存音乐请求:', { userId: user?.id, name, soundsCount: sounds?.length });
// 验证输入
if (!name || !sounds || !Array.isArray(sounds)) {
return new Response(JSON.stringify({
@ -47,6 +49,8 @@ export const POST: APIRoute = async ({ request }) => {
random_effects: random_effects || {},
});
console.log('✅ 音乐保存成功:', { id: music.id, name: music.name });
return new Response(JSON.stringify({
success: true,
message: '音乐保存成功',
@ -61,6 +65,18 @@ export const POST: APIRoute = async ({ request }) => {
});
} catch (error) {
console.error('❌ 保存音乐API错误:', error);
// 处理特定的数据库错误
if (error instanceof Error && error.message.includes('readonly')) {
return new Response(JSON.stringify({
error: '数据库权限错误,请检查文件权限'
}), {
status: 500,
headers: { 'Content-Type': 'application/json' }
});
}
return handleApiError(error, '保存音乐');
}
};