From a3c95ec19ba7a179d48590b08d04dea89bb06519 Mon Sep 17 00:00:00 2001 From: walle Date: Wed, 19 Nov 2025 15:21:49 +0800 Subject: [PATCH] =?UTF-8?q?feat:=20=E5=8D=87=E7=BA=A7=E5=88=B0=20v3.0.0=20?= =?UTF-8?q?-=20=E7=8B=AC=E7=AB=8B=E9=9F=B3=E4=B9=90=E6=92=AD=E6=94=BE?= =?UTF-8?q?=E7=B3=BB=E7=BB=9F=E4=B8=8E=20Docker=20=E6=95=B0=E6=8D=AE?= =?UTF-8?q?=E6=8C=81=E4=B9=85=E5=8C=96?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 🎉 版本升级: 2.5.0 → 3.0.0 🎵 音乐播放系统重构: - 独立的音乐播放系统,不影响当前选中声音 - 修复 React Hooks 调用错误 - 使用直接 Howl API 实现音频控制 - 添加播放/停止状态视觉反馈 - 组件显示逻辑完全分离 🐳 Docker 部署优化: - 所有 compose 文件添加 SQLite 数据库挂载 - 支持 WAL 模式和并发写入 - 数据持久化,容器重启不丢失数据 - 创建详细的 Docker 数据库挂载文档 🎨 UI/UX 改进: - 修复当前选中声音与音乐列表显示互斥问题 - 播放按钮状态动态显示 - 组件模块完全独立展示 🗄️ 数据库性能优化: - 启用 WAL 模式提高并发性能 - 优化 SQLite 配置参数 - 添加详细日志和错误处理 📦 新增文件: - docker-database-mount.md: Docker 数据库挂载说明文档 --- data/users.db | Bin 28672 -> 28672 bytes data/users.db-shm | Bin 0 -> 32768 bytes data/users.db-wal | Bin 0 -> 45352 bytes docker-compose.dev.yml | 2 + docker-compose.optimized.yml | 13 +- docker-compose.yml | 5 + docker-database-mount.md | 113 ++++++++ package.json | 2 +- .../selected-sounds-display.tsx | 266 ++++++++++++------ src/components/sounds/sounds.module.css | 9 + src/lib/database.ts | 12 +- src/pages/api/auth/music/save.ts | 16 ++ 12 files changed, 350 insertions(+), 88 deletions(-) create mode 100644 data/users.db-shm create mode 100644 data/users.db-wal create mode 100644 docker-database-mount.md diff --git a/data/users.db b/data/users.db index 0732016929950e8b99c6d3b2f82eed8656e3cae2..b4ae37a2406cc45fddd11f7f00fd628e8bc7a581 100644 GIT binary patch delta 44 zcmZp8z}WDBQ6@OhC$l6~AuYcsH?c&)m_dMniHX5ML4kpRL4Kl)Go$>*ga!EkEVK*o delta 44 zcmZp8z}WDBQ6@OhC$l6~AuYcsH?c&)m_dMnk&(ecL4kpRL2jaqGo#$bga!EkEPxB_ diff --git a/data/users.db-shm b/data/users.db-shm new file mode 100644 index 0000000000000000000000000000000000000000..a9d094707cf9b765ac3f224bd051f92ee19a6d7b GIT binary patch literal 32768 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 Cyil?L literal 0 HcmV?d00001 diff --git a/docker-compose.dev.yml b/docker-compose.dev.yml index 441d656..50c5e2a 100644 --- a/docker-compose.dev.yml +++ b/docker-compose.dev.yml @@ -31,6 +31,8 @@ services: - .:/app - /app/node_modules # 防止node_modules被覆盖 - moodist-dist:/app/dist + # 挂载 SQLite 数据库文件目录 + - ./data:/app/data:rw # 工作目录 working_dir: /app diff --git a/docker-compose.optimized.yml b/docker-compose.optimized.yml index 314946b..aecdf00 100644 --- a/docker-compose.optimized.yml +++ b/docker-compose.optimized.yml @@ -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 diff --git a/docker-compose.yml b/docker-compose.yml index d389090..062523a 100644 --- a/docker-compose.yml +++ b/docker-compose.yml @@ -8,3 +8,8 @@ services: restart: always ports: - '8080:8080' + volumes: + # 挂载 SQLite 数据库文件和 WAL 文件 + - ./data:/app/data:rw + environment: + - NODE_ENV=production diff --git a/docker-database-mount.md b/docker-database-mount.md new file mode 100644 index 0000000..463d793 --- /dev/null +++ b/docker-database-mount.md @@ -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 +``` + +## 开发环境注意事项 + +开发环境中,数据库文件会被实时同步到本地文件系统,便于: +- 调试和测试 +- 数据分析 +- 快速重置测试数据 \ No newline at end of file diff --git a/package.json b/package.json index 5a97ab2..1650148 100644 --- a/package.json +++ b/package.json @@ -1,7 +1,7 @@ { "name": "moodist", "type": "module", - "version": "2.5.0", + "version": "3.0.0", "scripts": { "dev": "astro dev", "start": "astro dev", diff --git a/src/components/selected-sounds-display/selected-sounds-display.tsx b/src/components/selected-sounds-display/selected-sounds-display.tsx index 46fd461..f4c7117 100644 --- a/src/components/selected-sounds-display/selected-sounds-display.tsx +++ b/src/components/selected-sounds-display/selected-sounds-display.tsx @@ -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(null); const [musicName, setMusicName] = useState(''); + // 独立的音乐播放状态 + const [currentlyPlayingMusic, setCurrentlyPlayingMusic] = useState(null); + const musicHowlInstances = useRef>({}); + 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[] = []; + + 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((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 (
- {/* 当前选中声音模块 */} -
-
-

- - 当前选中的声音 -

- + {/* 当前选中声音模块 - 只有选中声音时才显示 */} + {selectedSounds.length > 0 && ( +
+
+

+ + 当前选中的声音 +

+ +
+ + {/* 音乐名称配置区域 */} + {expandedCurrent && ( +
+ setMusicName(e.target.value)} + placeholder="音乐名称" + className={styles.musicNameInput} + maxLength={50} + /> + +
+ )} + + {/* 选中的声音展示 */} + {expandedCurrent && ( +
+ + {selectedSounds.map((sound) => ( + +
+ )}
- - {/* 音乐名称配置区域 */} - {expandedCurrent && ( -
- setMusicName(e.target.value)} - placeholder="音乐名称" - className={styles.musicNameInput} - maxLength={50} - /> - -
- )} - - {/* 选中的声音展示 */} - {expandedCurrent && ( -
- - {selectedSounds.map((sound) => ( - -
- )} -
+ )} {/* 音乐列表模块 - 只有登录用户且有音乐时才显示 */} {isAuthenticated && savedMusicList.length > 0 && ( @@ -400,6 +493,21 @@ export function SelectedSoundsDisplay() {
) : (
+
{music.name}
@@ -443,18 +551,6 @@ export function SelectedSoundsDisplay() { {/* 展开时显示的声音内容 */} {expandedMusic.has(music.id) && (
- {/* 播放按钮 */} -
- -
- {/* 声音组件展示 */}
diff --git a/src/components/sounds/sounds.module.css b/src/components/sounds/sounds.module.css index 4fd2841..1a27557 100644 --- a/src/components/sounds/sounds.module.css +++ b/src/components/sounds/sounds.module.css @@ -181,6 +181,15 @@ background: var(--color-foreground-subtle); } +.playButton.playing { + background: #e74c3c; + color: white; +} + +.playButton.playing:hover { + background: #c0392b; +} + /* 音乐名称 */ .musicName { font-size: 14px; diff --git a/src/lib/database.ts b/src/lib/database.ts index b693fdf..50df099 100644 --- a/src/lib/database.ts +++ b/src/lib/database.ts @@ -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(` diff --git a/src/pages/api/auth/music/save.ts b/src/pages/api/auth/music/save.ts index 8b9d7cd..2d93e9c 100644 --- a/src/pages/api/auth/music/save.ts +++ b/src/pages/api/auth/music/save.ts @@ -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, '保存音乐'); } }; \ No newline at end of file