diff --git a/App.tsx b/App.tsx index d10e764..c1d4926 100644 --- a/App.tsx +++ b/App.tsx @@ -121,7 +121,8 @@ const App: React.FC = () => { const [isProcessing, setIsProcessing] = useState(false); const [editingTransaction, setEditingTransaction] = useState(null); const [isEditingFromChat, setIsEditingFromChat] = useState(false); - const [inputMode, setInputMode] = useState<'voice' | 'keyboard'>('voice'); + const [inputMode, setInputMode] = useState<'voice' | 'keyboard'>('keyboard'); + const [isVoiceServiceAvailable, setIsVoiceServiceAvailable] = useState(true); const [token, setToken] = useState(null); const [userInfo, setUserInfo] = useState(null); @@ -151,6 +152,12 @@ const App: React.FC = () => { useEffect(() => { console.log('当前URL:', window.location.href); + // 检查浏览器是否支持语音识别 + const SpeechRecognition = (window as any).SpeechRecognition || (window as any).webkitSpeechRecognition; + if (!SpeechRecognition) { + setIsVoiceServiceAvailable(false); + } + // 检查是否存在token,如果不存在则调用登录接口 checkAndLogin().then((userInfo) => { // 登录成功后加载数据 @@ -264,33 +271,46 @@ const App: React.FC = () => { relatedTransactionId: msg.transaction_id || msg.relatedTransactionId })); // 更新本地缓存 - setMessages(messagesFromDB); + setMessages(prev => { + // 如果已经有消息了(比如调试消息),保留它们并合并 + const existingDebugMessages = prev.filter(m => m.id.startsWith('debug-')); + return [...messagesFromDB, ...existingDebugMessages]; + }); console.log('消息列表已从数据库加载:', messagesFromDB.length); } else { // 如果数据库中没有消息,显示初始欢迎消息 const aiName = aiSettings.name; - setMessages([{ - id: 'init', - role: 'assistant', - content: `我是${aiName}。✨\n想记账?快说!🎤`, - timestamp: Date.now() - }]); + setMessages(prev => { + const existingDebugMessages = prev.filter(m => m.id.startsWith('debug-')); + return [{ + id: 'init', + role: 'assistant', + content: `我是${aiName}。✨\n想记账?快说!🎤`, + timestamp: Date.now() + }, ...existingDebugMessages]; + }); } } catch (error) { console.error('加载消息列表失败:', error); // 失败时尝试从本地存储加载 const storedMessages = localStorage.getItem('cui_messages'); if (storedMessages) { - setMessages(JSON.parse(storedMessages)); + setMessages(prev => { + const existingDebugMessages = prev.filter(m => m.id.startsWith('debug-')); + return [...JSON.parse(storedMessages), ...existingDebugMessages]; + }); } else { // 显示初始欢迎消息 const aiName = aiSettings.name; - setMessages([{ - id: 'init', - role: 'assistant', - content: `我是${aiName}。✨\n想记账?快说!🎤`, - timestamp: Date.now() - }]); + setMessages(prev => { + const existingDebugMessages = prev.filter(m => m.id.startsWith('debug-')); + return [{ + id: 'init', + role: 'assistant', + content: `我是${aiName}。✨\n想记账?快说!🎤`, + timestamp: Date.now() + }, ...existingDebugMessages]; + }); } } @@ -482,6 +502,11 @@ const App: React.FC = () => { } }; + const handleDebugLog = React.useCallback((content: string) => { + // 调试信息仅打印到控制台,不再添加到消息列表 + console.log(`🔧 [调试信息] ${content}`); + }, []); + const handleSendMessage = React.useCallback(async (text: string, imageBase64?: string, mimeType?: string) => { if (!text.trim() && !imageBase64) return; @@ -799,7 +824,10 @@ const App: React.FC = () => {
-
) : ( - handleSendMessage(text)} isProcessing={isProcessing} isFullWidth /> + handleSendMessage(text)} + isProcessing={isProcessing} + isFullWidth + onDebug={handleDebugLog} + onAvailabilityChange={(isAvailable) => setIsVoiceServiceAvailable(isAvailable)} + isServiceAvailable={isVoiceServiceAvailable} + /> )} diff --git a/components/VoiceInput.tsx b/components/VoiceInput.tsx index da3aa65..6c3a823 100644 --- a/components/VoiceInput.tsx +++ b/components/VoiceInput.tsx @@ -1,47 +1,74 @@ -import React, { useState, useEffect, useRef } from 'react'; +import React, { useState, useEffect, useRef, useCallback } from 'react'; interface VoiceInputProps { onTranscript: (text: string) => void; isProcessing: boolean; isFullWidth?: boolean; + onDebug?: (message: string) => void; + onAvailabilityChange?: (isAvailable: boolean) => void; + isServiceAvailable?: boolean; } -const VoiceInput: React.FC = ({ onTranscript, isProcessing, isFullWidth = false }) => { - const [isListening, setIsListening] = useState(false); +const VoiceInput: React.FC = ({ + onTranscript, + isProcessing, + isFullWidth = false, + onDebug, + onAvailabilityChange, + isServiceAvailable = true +}) => { + const [voiceState, setVoiceState] = useState<'idle' | 'starting' | 'listening'>('idle'); + const [isInternalDenied, setIsInternalDenied] = useState(false); + + const isServiceDenied = !isServiceAvailable || isInternalDenied; const recognitionRef = useRef(null); - const isStartingRef = useRef(false); - const onTranscriptRef = useRef(onTranscript); + const isHoldingRef = useRef(false); + const startTimestampRef = useRef(0); + const startTimeoutRef = useRef(null); + const lastEventTimeRef = useRef(0); + const activeEventRef = useRef<'pointer' | 'touch' | null>(null); - // 始终保持 ref 为最新,避免在 useEffect 中依赖 onTranscript 导致重复初始化 + const onTranscriptRef = useRef(onTranscript); useEffect(() => { onTranscriptRef.current = onTranscript; }, [onTranscript]); - const initRecognition = () => { - if (!('SpeechRecognition' in window || 'webkitSpeechRecognition' in window)) return; + // 初始化语音识别实例 + const initRecognition = useCallback(() => { + if (typeof window === 'undefined') return null; - // 如果已有实例,先清理 + const SpeechRecognition = (window as any).SpeechRecognition || (window as any).webkitSpeechRecognition; + if (!SpeechRecognition) { + onAvailabilityChange?.(false); + return null; + } + + // 彻底清理并销毁旧实例,这是 iOS 修复 service-not-allowed 的关键 if (recognitionRef.current) { try { + recognitionRef.current.onstart = null; + recognitionRef.current.onend = null; + recognitionRef.current.onerror = null; + recognitionRef.current.onresult = null; recognitionRef.current.abort(); } catch (e) {} } - const SpeechRecognition = (window as any).SpeechRecognition || (window as any).webkitSpeechRecognition; const recognition = new SpeechRecognition(); - - recognition.continuous = true; - recognition.lang = 'zh-CN'; + recognition.continuous = false; + // 针对 iOS 优化语言标识符 + recognition.lang = 'zh-Hans-CN'; recognition.interimResults = true; recognition.onstart = () => { - setIsListening(true); - isStartingRef.current = false; - console.log('Speech recognition started'); + if (startTimeoutRef.current) clearTimeout(startTimeoutRef.current); + setVoiceState('listening'); + onDebug?.('DEBUG: recognition.onstart 触发'); }; recognition.onresult = (event: any) => { const results = event.results; + onDebug?.(`DEBUG: recognition.onresult 触发, isFinal: ${results[event.resultIndex]?.isFinal}`); for (let i = event.resultIndex; i < results.length; i++) { if (results[i].isFinal) { const transcript = results[i][0].transcript; @@ -51,55 +78,54 @@ const VoiceInput: React.FC = ({ onTranscript, isProcessing, isF }; recognition.onerror = async (event: any) => { - console.error('Speech recognition error', event.error); + if (startTimeoutRef.current) clearTimeout(startTimeoutRef.current); + const errorType = event.error; + onDebug?.(`DEBUG: recognition.onerror 触发, error: ${errorType}`); - let message = ''; - switch (event.error) { - case 'network': - message = "语音识别网络连接失败。请检查网络或尝试切换浏览器(推荐 Chrome/Edge)。"; - break; - case 'not-allowed': - case 'service-not-allowed': - // 移动端初次授权时可能触发 race condition:用户点击“允许”但 start() 已超时失败 - // 此时我们通过 getUserMedia 探测真实权限状态 + setVoiceState('idle'); + isHoldingRef.current = false; + activeEventRef.current = null; + + if (errorType === 'service-not-allowed' || errorType === 'not-allowed') { + setIsInternalDenied(true); + onAvailabilityChange?.(false); + + if (errorType === 'service-not-allowed') { + const isIOS = /iPad|iPhone|iPod/.test(navigator.userAgent); + if (isIOS) { + alert("语音识别服务不可用。\n\n解决办法:\n1. 确保您的 iPhone 已开启“听写”功能(设置 -> 通用 -> 键盘 -> 启用听写)\n2. 确保您的 Siri 已开启\n3. 检查网络连接是否正常。"); + } else { + alert("语音识别服务被拒绝,请稍后重试。"); + } + } else { + // not-allowed 处理 + onDebug?.(`DEBUG: 检测到错误 not-allowed,尝试探测麦克风硬件...`); try { const stream = await navigator.mediaDevices.getUserMedia({ audio: true }); - // 如果能获取到流,说明权限其实是有的,只是刚才 start() 冲突了 stream.getTracks().forEach(t => t.stop()); - console.log('Permission probe successful, refreshing recognition instance...'); - isStartingRef.current = false; - setIsListening(false); - initRecognition(); // 静默重置实例 - return; // 不弹窗,用户再次点击就会成功 - } catch (e) { - // 探测也失败,说明是真的被拒绝了 - message = "麦克风访问被拒绝。\n\n解决办法:\n1. 点击地址栏左侧的“锁”或“设置”图标\n2. 找到“麦克风”并设为“允许”\n3. 刷新页面后重试"; + onDebug?.('DEBUG: 麦克风硬件探测成功'); + alert("麦克风访问被拒绝。\n\n请在浏览器设置中允许麦克风权限后重试。"); + } catch (e: any) { + onDebug?.(`DEBUG: 麦克风硬件探测失败: ${e.message || e.name}`); + alert("麦克风访问被拒绝,且硬件探测失败。\n\n请检查设备设置并确保浏览器拥有麦克风权限。"); } - break; - case 'no-speech': - return; - case 'aborted': - return; - default: - break; + } } - - if (message) alert(message); - - setIsListening(false); - isStartingRef.current = false; - // 出错后重新初始化,确保下次点击可用 - initRecognition(); }; recognition.onend = () => { - console.log('Speech recognition ended'); - setIsListening(false); - isStartingRef.current = false; + if (startTimeoutRef.current) clearTimeout(startTimeoutRef.current); + setVoiceState('idle'); + onDebug?.('DEBUG: recognition.onend 触发'); + if (isHoldingRef.current) { + isHoldingRef.current = false; + activeEventRef.current = null; + } }; recognitionRef.current = recognition; - }; + return recognition; + }, [onDebug]); useEffect(() => { initRecognition(); @@ -110,99 +136,188 @@ const VoiceInput: React.FC = ({ onTranscript, isProcessing, isF } catch (e) {} } }; - }, []); + }, [initRecognition]); - const startListening = (e: React.PointerEvent) => { - e.stopPropagation(); - if (e.cancelable) e.preventDefault(); + const stopListening = useCallback((e: React.PointerEvent | React.TouchEvent | React.MouseEvent) => { + const eventType = e.type.startsWith('touch') ? 'touch' : 'pointer'; + onDebug?.(`DEBUG: stopListening 触发, event: ${e.type}, state: ${voiceState}, isHolding: ${isHoldingRef.current}`); - // 如果已经在处理中、正在监听或正在启动,则忽略 - if (isProcessing || isListening || isStartingRef.current) return; - - // 获取指针捕获,确保即使手指移出按钮范围也能触发 onPointerUp - try { - (e.currentTarget as HTMLElement).setPointerCapture(e.pointerId); - } catch (err) { - console.warn("Failed to set pointer capture", err); + // 如果不是当前激活的事件类型,忽略(除非已经没有激活类型了) + if (activeEventRef.current && activeEventRef.current !== eventType) { + return; } - if (recognitionRef.current) { - // 检查是否为安全上下文 (HTTPS 或 localhost) - if (!window.isSecureContext) { - alert("语音识别失败:浏览器限制在非 HTTPS 环境下使用麦克风。请确保使用 HTTPS 访问,或在 PC 端 Chrome 的 'Insecure origins treated as secure' 中设置白名单。"); - return; - } + isHoldingRef.current = false; + activeEventRef.current = null; + + if (startTimeoutRef.current) { + clearTimeout(startTimeoutRef.current); + startTimeoutRef.current = null; + } + // 处理极短时间的点击,防止闪现 + const duration = Date.now() - startTimestampRef.current; + if (duration < 300) { + // 如果点击时间太短,稍微延迟停止,或者直接停止 + setTimeout(() => { + try { recognitionRef.current?.stop(); } catch (e) {} + }, 300 - duration); + } else { + try { recognitionRef.current?.stop(); } catch (e) {} + } + }, [voiceState, onDebug]); + + const startListening = useCallback((e: React.PointerEvent | React.TouchEvent | React.MouseEvent) => { + if (e.cancelable) e.preventDefault(); + e.stopPropagation(); + + const eventType = e.type.startsWith('touch') ? 'touch' : 'pointer'; + onDebug?.(`DEBUG: startListening 触发, type: ${e.type}, currentActive: ${activeEventRef.current}`); + + if (activeEventRef.current && activeEventRef.current !== eventType) { + onDebug?.(`DEBUG: 忽略冲突事件类型: ${eventType}`); + return; + } + + const now = Date.now(); + if (now - lastEventTimeRef.current < 400) return; // 略微增加防抖 + lastEventTimeRef.current = now; + + if (isProcessing || voiceState !== 'idle' || isHoldingRef.current || isServiceDenied) return; + + activeEventRef.current = eventType; + isHoldingRef.current = true; + startTimestampRef.current = now; + + if ('vibrate' in navigator) { + try { navigator.vibrate(20); } catch (e) {} + } + + if (!window.isSecureContext) { + alert("语音识别失败:请使用 HTTPS 协议访问以开启麦克风权限。"); + isHoldingRef.current = false; + activeEventRef.current = null; + return; + } + + // 核心修复:针对 iOS,每次点击都强制获取一个全新的实例 + onDebug?.('DEBUG: 正在获取全新识别实例...'); + const recognition = initRecognition(); + + if (recognition) { try { - isStartingRef.current = true; - recognitionRef.current.start(); - // UI 状态会在 onstart 回调中更新,以保证真实感 - } catch (err) { - console.error("Failed to start recognition", err); - isStartingRef.current = false; - setIsListening(false); + // 指针捕获 + if ('pointerId' in e && (e.currentTarget as any).setPointerCapture) { + try { (e.currentTarget as any).setPointerCapture(e.pointerId); } catch (err) {} + } + + setVoiceState('starting'); + + // 核心修复:针对 iOS 增加 100ms 软启动延迟,避免手势冲突 + setTimeout(() => { + if (isHoldingRef.current) { + onDebug?.('DEBUG: 延时结束,调用 recognition.start()'); + try { + recognition.start(); + } catch (err: any) { + onDebug?.(`DEBUG: 异步 start 失败: ${err.message || err}`); + setVoiceState('idle'); + isHoldingRef.current = false; + activeEventRef.current = null; + } + } + }, 100); + + startTimeoutRef.current = setTimeout(() => { + if (isHoldingRef.current && voiceState === 'starting') { + onDebug?.("DEBUG: 启动超时保护触发"); + stopListening(e); + } + }, 5000); + } catch (err: any) { + onDebug?.(`DEBUG: recognition.start 抛出异常: ${err.message || err}`); + setVoiceState('idle'); + isHoldingRef.current = false; + activeEventRef.current = null; } } else { - if (!window.isSecureContext) { - alert("浏览器不支持语音识别:当前环境不是安全上下文 (HTTPS)。"); - } else { - alert("您的浏览器不支持语音识别,请使用 Chrome 或 Edge。"); - } + alert("您的浏览器不支持语音识别,请尝试使用 Chrome 或 Edge。"); + isHoldingRef.current = false; + activeEventRef.current = null; } - }; + }, [isProcessing, voiceState, initRecognition, onDebug, stopListening]); - const stopListening = (e: React.PointerEvent) => { - e.stopPropagation(); - - // 只有在正在运行或正在启动时才尝试停止 - if (isListening || isStartingRef.current) { - try { - recognitionRef.current?.stop(); - } catch (err) { - console.error("Failed to stop recognition", err); - // 发生错误时强制重置本地状态 - setIsListening(false); - isStartingRef.current = false; - } + // 处理指针/触摸离开或取消的情况 + const handleCancel = useCallback((e: any) => { + onDebug?.(`DEBUG: handleCancel 触发, event: ${e.type}, isHolding: ${isHoldingRef.current}`); + if (isHoldingRef.current) { + stopListening(e); } - }; + }, [stopListening, onDebug]); return (
- {isListening && isFullWidth && ( + {voiceState !== 'idle' && !isServiceDenied && isFullWidth && (
-
- 正在录音... +
+ {voiceState === 'listening' ? '正在录音...' : '准备录音...'}
)}