import { create } from 'zustand'; import { devtools, persist } from 'zustand/middleware'; import { Speak, getDayOfYear, CreateSpeakData } from '../modules/speak-db/speak'; import { speakService } from '../modules/speak-db/speak-service'; import { getText } from '../modules/text'; import { useSettingStore } from './settingStore'; interface VoiceState { // 状态数据 voiceList: Speak[]; isLoading: boolean; error: string | null; currentDay: number; // 动作方法 initialize: () => Promise; addVoice: (url: string, duration: number, audioBlob?: Blob) => Promise; updateVoice: (id: string, updates: Partial) => Promise; deleteVoice: (id: string) => Promise; recognizeVoice: (id: string) => Promise; clearTodayVoices: () => Promise; generateAudioUrls: () => Promise; refreshList: () => Promise; setError: (error: string | null) => void; setLoading: (loading: boolean) => void; } // 辅助函数:将 Blob 转换为 base64 字符串 const blobToBase64 = (blob: Blob): Promise => { return new Promise((resolve, reject) => { const reader = new FileReader(); reader.onload = () => { const result = reader.result as string; // 移除 data URL 前缀,只保留 base64 数据 resolve(result.split(',')[1] || result); }; reader.onerror = reject; reader.readAsDataURL(blob); }); }; // 辅助函数:将 base64 字符串转换为 Blob URL const base64ToUrl = (base64: string, mimeType: string = 'audio/wav'): string => { try { // 如果已经是 blob URL,直接返回 if (base64.startsWith('blob:')) { return base64; } // 将 base64 转换为 ArrayBuffer const binaryString = window.atob(base64); const bytes = new Uint8Array(binaryString.length); for (let i = 0; i < binaryString.length; i++) { bytes[i] = binaryString.charCodeAt(i); } // 创建 Blob 和 URL const blob = new Blob([bytes], { type: mimeType }); return URL.createObjectURL(blob); } catch (error) { console.error('转换 base64 到 URL 失败:', error); return ''; } }; export const useVoiceStore = create()( devtools( (set, get) => ({ // 初始状态 voiceList: [], isLoading: false, error: null, currentDay: getDayOfYear(), // 初始化:从 IndexedDB 获取当天的记录 initialize: async () => { const { setLoading, setError, generateAudioUrls } = get(); try { setLoading(true); setError(null); // 初始化 speak service await speakService.init(); // 获取当天的语音记录 const currentDay = getDayOfYear(); const todayVoices = await speakService.getSpeaksByDay(currentDay); set({ voiceList: todayVoices, currentDay: currentDay }); // 为获取到的记录生成 audio URLs await generateAudioUrls(); } catch (error) { console.error('初始化语音列表失败:', error); setError(error instanceof Error ? error.message : '初始化失败'); } finally { setLoading(false); } }, // 添加新的语音记录 addVoice: async (url: string, duration: number, audioBlob?: Blob) => { const { setError } = get(); const autoRecognize = useSettingStore.getState().autoRecognize; try { setError(null); let fileData: string | undefined; // 如果提供了 audioBlob,将其转换为 base64 保存到 IndexedDB if (audioBlob) { fileData = await blobToBase64(audioBlob); } // 创建语音记录(不保存 url,只保存 base64 数据) const speakData = { duration: Math.ceil(duration), file: fileData, // 保存 base64 数据而不是 url day: getDayOfYear(), no: 0, // 将由 service 自动生成 timestamp: Date.now(), type: 'normal' as const, text: '', // 初始为空 }; if (autoRecognize) { speakData.text = await getText(fileData || '').then(res => res.text); } // 保存到 IndexedDB(不包含 url) const newSpeak = await speakService.createSpeakAuto(speakData); // 为新记录生成 URL 并添加到状态 const speakWithUrl = { ...newSpeak, url: newSpeak.file ? base64ToUrl(newSpeak.file) : url }; set(state => ({ voiceList: [...state.voiceList, speakWithUrl] })); return speakWithUrl; } catch (error) { console.error('添加语音记录失败:', error); setError(error instanceof Error ? error.message : '添加失败'); throw error; } }, // 更新语音记录 updateVoice: async (id: string, updates: Partial) => { const { setError } = get(); try { setError(null); // 从更新数据中移除 url,因为 url 不应该保存到 IndexedDB const { url, ...updatesWithoutUrl } = updates; // 更新 IndexedDB 中的记录 const updatedSpeak = await speakService.updateSpeak(id, updatesWithoutUrl); // 更新状态中的记录 set(state => ({ voiceList: state.voiceList.map(voice => { if (voice.id === id) { const updated = { ...voice, ...updates }; // 如果更新了 file 数据,重新生成 URL if (updatesWithoutUrl.file) { updated.url = base64ToUrl(updatesWithoutUrl.file); } return updated; } return voice; }) })); } catch (error) { console.error('更新语音记录失败:', error); setError(error instanceof Error ? error.message : '更新失败'); throw error; } }, // 删除语音记录 deleteVoice: async (id: string) => { const { setError } = get(); try { setError(null); // 从 IndexedDB 删除 await speakService.deleteSpeak(id); // 从状态中移除并释放 URL set(state => { const voiceToDelete = state.voiceList.find(voice => voice.id === id); if (voiceToDelete && voiceToDelete.url && voiceToDelete.url.startsWith('blob:')) { URL.revokeObjectURL(voiceToDelete.url); } return { voiceList: state.voiceList.filter(voice => voice.id !== id) }; }); } catch (error) { console.error('删除语音记录失败:', error); setError(error instanceof Error ? error.message : '删除失败'); throw error; } }, // 识别语音记录 recognizeVoice: async (id: string) => { const { setError } = get(); try { setError(null); // 获取语音记录 const voice = get().voiceList.find(v => v.id === id); if (!voice || !voice.file) { throw new Error('找不到语音记录或音频数据'); } // 调用语音识别API const result = await getText(voice.file); const recognizedText = result.text; if (!recognizedText) { throw new Error('语音识别失败,未能获取到文字内容'); } // 更新数据库中的记录 await speakService.updateSpeak(id, { text: recognizedText }); // 更新状态中的记录 set(state => ({ voiceList: state.voiceList.map(voice => voice.id === id ? { ...voice, text: recognizedText } : voice ) })); return recognizedText; } catch (error) { console.error('语音识别失败:', error); setError(error instanceof Error ? error.message : '语音识别失败'); throw error; } }, // 清空今天的语音记录 clearTodayVoices: async () => { const { setError, currentDay } = get(); try { setError(null); // 从 IndexedDB 清空今天的记录 await speakService.deleteSpeaksByDay(currentDay); // 清空状态并释放所有 URL set(state => { state.voiceList.forEach(voice => { if (voice.url && voice.url.startsWith('blob:')) { URL.revokeObjectURL(voice.url); } }); return { voiceList: [] }; }); } catch (error) { console.error('清空今天语音记录失败:', error); setError(error instanceof Error ? error.message : '清空失败'); throw error; } }, // 为所有记录生成音频 URL generateAudioUrls: async () => { const { voiceList } = get(); set(state => ({ voiceList: state.voiceList.map(voice => { // 如果已经有 URL 且是 blob URL,跳过 if (voice.url && voice.url.startsWith('blob:')) { return voice; } // 如果有 file 数据,从 base64 生成 URL if (voice.file) { return { ...voice, url: base64ToUrl(voice.file) }; } return voice; }) })); }, // 刷新列表(重新从 IndexedDB 获取) refreshList: async () => { const { initialize } = get(); await initialize(); }, // 设置错误信息 setError: (error: string | null) => { set({ error }); }, // 设置加载状态 setLoading: (loading: boolean) => { set({ isLoading: loading }); } }), { name: 'voice-store', // persist key } ) ); // 导出类型以便其他地方使用 export type { VoiceState }; // 辅助 hooks export const useVoiceList = () => useVoiceStore(state => state.voiceList); export const useVoiceLoading = () => useVoiceStore(state => state.isLoading); export const useVoiceError = () => useVoiceStore(state => state.error); // 清理函数:页面卸载时释放所有 blob URLs export const cleanupVoiceUrls = () => { const { voiceList } = useVoiceStore.getState(); voiceList.forEach(voice => { if (voice.url && voice.url.startsWith('blob:')) { URL.revokeObjectURL(voice.url); } }); }; // 在页面卸载时自动清理 if (typeof window !== 'undefined') { window.addEventListener('beforeunload', cleanupVoiceUrls); }