Files
app_02_bill/components/VoiceInput.tsx
karlcarlo d4aa67b97d refactor(api): 精简并移除多余的API接口和类型定义
- 移除多余的统计、预算、AI设置、用户偏好、数据同步及AI处理相关类型定义
- 删除大量未使用的API请求函数,简化导出接口
- 保留核心的用户认证、交易记录、聊天消息和账单提醒相关接口
- 优化App组件状态初始化,改为从localStorage加载持久化数据,避免重复请求
- VoiceInput组件重构,改用持续监听及更健壮的权限和错误处理逻辑
- 优化语音识别启动和停止机制,提升用户体验和错误恢复能力
- 更新依赖顺序,无功能性变更
2026-01-23 11:15:25 +08:00

214 lines
7.6 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
import React, { useState, useEffect, useRef } from 'react';
interface VoiceInputProps {
onTranscript: (text: string) => void;
isProcessing: boolean;
isFullWidth?: boolean;
}
const VoiceInput: React.FC<VoiceInputProps> = ({ onTranscript, isProcessing, isFullWidth = false }) => {
const [isListening, setIsListening] = useState(false);
const recognitionRef = useRef<any>(null);
const isStartingRef = useRef(false);
const onTranscriptRef = useRef(onTranscript);
// 始终保持 ref 为最新,避免在 useEffect 中依赖 onTranscript 导致重复初始化
useEffect(() => {
onTranscriptRef.current = onTranscript;
}, [onTranscript]);
const initRecognition = () => {
if (!('SpeechRecognition' in window || 'webkitSpeechRecognition' in window)) return;
// 如果已有实例,先清理
if (recognitionRef.current) {
try {
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.interimResults = true;
recognition.onstart = () => {
setIsListening(true);
isStartingRef.current = false;
console.log('Speech recognition started');
};
recognition.onresult = (event: any) => {
const results = event.results;
for (let i = event.resultIndex; i < results.length; i++) {
if (results[i].isFinal) {
const transcript = results[i][0].transcript;
if (transcript) onTranscriptRef.current(transcript);
}
}
};
recognition.onerror = async (event: any) => {
console.error('Speech recognition error', event.error);
let message = '';
switch (event.error) {
case 'network':
message = "语音识别网络连接失败。请检查网络或尝试切换浏览器(推荐 Chrome/Edge)。";
break;
case 'not-allowed':
case 'service-not-allowed':
// 移动端初次授权时可能触发 race condition:用户点击“允许”但 start() 已超时失败
// 此时我们通过 getUserMedia 探测真实权限状态
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. 刷新页面后重试";
}
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;
};
recognitionRef.current = recognition;
};
useEffect(() => {
initRecognition();
return () => {
if (recognitionRef.current) {
try {
recognitionRef.current.abort();
} catch (e) {}
}
};
}, []);
const startListening = (e: React.PointerEvent) => {
e.stopPropagation();
if (e.cancelable) e.preventDefault();
// 如果已经在处理中、正在监听或正在启动,则忽略
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 (recognitionRef.current) {
// 检查是否为安全上下文 (HTTPS 或 localhost)
if (!window.isSecureContext) {
alert("语音识别失败:浏览器限制在非 HTTPS 环境下使用麦克风。请确保使用 HTTPS 访问,或在 PC 端 Chrome 的 'Insecure origins treated as secure' 中设置白名单。");
return;
}
try {
isStartingRef.current = true;
recognitionRef.current.start();
// UI 状态会在 onstart 回调中更新,以保证真实感
} catch (err) {
console.error("Failed to start recognition", err);
isStartingRef.current = false;
setIsListening(false);
}
} else {
if (!window.isSecureContext) {
alert("浏览器不支持语音识别:当前环境不是安全上下文 (HTTPS)。");
} else {
alert("您的浏览器不支持语音识别,请使用 Chrome 或 Edge。");
}
}
};
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;
}
}
};
return (
<div className={`${isFullWidth ? 'w-full' : ''} relative select-none touch-none`}>
<button
onPointerDown={startListening}
onPointerUp={stopListening}
onPointerCancel={stopListening}
onClick={(e) => e.preventDefault()}
onContextMenu={(e) => e.preventDefault()}
disabled={isProcessing}
style={{ WebkitTouchCallout: 'none' }}
className={`relative flex items-center justify-center transition-all duration-300 active:scale-95 shadow-lg ${
isFullWidth
? 'w-full py-4 rounded-2xl font-bold text-sm tracking-widest'
: 'w-14 h-14 rounded-full'
} ${
isListening
? 'bg-rose-500 text-white shadow-rose-200 scale-105'
: 'bg-indigo-600 text-white shadow-indigo-100'
} ${isProcessing ? 'opacity-50 grayscale cursor-not-allowed' : ''}`}
>
{isListening && (
<div className="absolute inset-0 rounded-2xl animate-ping bg-rose-400 opacity-20"></div>
)}
<div className="flex items-center gap-2 z-10">
<svg xmlns="http://www.w3.org/2000/svg" className={`${isFullWidth ? 'h-5 w-5' : 'h-6 w-6'}`} fill="none" viewBox="0 0 24 24" stroke="currentColor">
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2.5} d="M19 11a7 7 0 01-7 7m0 0a7 7 0 01-7-7m7 7v4m0 0H8m4 0h4m-4-8a3 3 0 01-3-3V5a3 3 0 116 0v6a3 3 0 01-3 3z" />
</svg>
{isFullWidth && <span>{isListening ? '松开 结束' : '按住 说话'}</span>}
</div>
</button>
{isListening && isFullWidth && (
<div className="absolute -top-16 left-0 right-0 flex justify-center animate-bounce">
<div className="bg-rose-500 text-white px-4 py-2 rounded-full text-xs font-bold shadow-xl border-2 border-white">
正在录音...
</div>
</div>
)}
</div>
);
};
export default VoiceInput;