refactor(api): 精简并移除多余的API接口和类型定义
- 移除多余的统计、预算、AI设置、用户偏好、数据同步及AI处理相关类型定义 - 删除大量未使用的API请求函数,简化导出接口 - 保留核心的用户认证、交易记录、聊天消息和账单提醒相关接口 - 优化App组件状态初始化,改为从localStorage加载持久化数据,避免重复请求 - VoiceInput组件重构,改用持续监听及更健壮的权限和错误处理逻辑 - 优化语音识别启动和停止机制,提升用户体验和错误恢复能力 - 更新依赖顺序,无功能性变更
This commit is contained in:
@@ -9,66 +9,160 @@ interface VoiceInputProps {
|
||||
const VoiceInput: React.FC<VoiceInputProps> = ({ onTranscript, isProcessing, isFullWidth = false }) => {
|
||||
const [isListening, setIsListening] = useState(false);
|
||||
const recognitionRef = useRef<any>(null);
|
||||
const timerRef = useRef<number | null>(null);
|
||||
|
||||
const isStartingRef = useRef(false);
|
||||
const onTranscriptRef = useRef(onTranscript);
|
||||
|
||||
// 始终保持 ref 为最新,避免在 useEffect 中依赖 onTranscript 导致重复初始化
|
||||
useEffect(() => {
|
||||
if ('SpeechRecognition' in window || 'webkitSpeechRecognition' in window) {
|
||||
const SpeechRecognition = window.SpeechRecognition || window.webkitSpeechRecognition;
|
||||
recognitionRef.current = new SpeechRecognition();
|
||||
recognitionRef.current.continuous = false;
|
||||
recognitionRef.current.lang = 'zh-CN';
|
||||
recognitionRef.current.interimResults = false;
|
||||
|
||||
recognitionRef.current.onresult = (event: any) => {
|
||||
const transcript = event.results[0][0].transcript;
|
||||
if (transcript) onTranscript(transcript);
|
||||
};
|
||||
|
||||
recognitionRef.current.onerror = (event: any) => {
|
||||
if (event.error === 'network') {
|
||||
alert("语音识别网络连接失败。请检查网络或尝试切换浏览器(推荐 Chrome/Edge)。");
|
||||
}
|
||||
console.error('Speech recognition error', event.error);
|
||||
};
|
||||
|
||||
recognitionRef.current.onend = () => {
|
||||
setIsListening(false);
|
||||
isStartingRef.current = false;
|
||||
};
|
||||
}
|
||||
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();
|
||||
setIsListening(true);
|
||||
// UI 状态会在 onstart 回调中更新,以保证真实感
|
||||
} catch (err) {
|
||||
console.error("Failed to start recognition", err);
|
||||
// 如果 start 失败,立即重置状态,否则会锁死
|
||||
isStartingRef.current = false;
|
||||
setIsListening(false);
|
||||
}
|
||||
} else {
|
||||
alert("您的浏览器不支持语音识别,请使用 Chrome 或 Edge。");
|
||||
if (!window.isSecureContext) {
|
||||
alert("浏览器不支持语音识别:当前环境不是安全上下文 (HTTPS)。");
|
||||
} else {
|
||||
alert("您的浏览器不支持语音识别,请使用 Chrome 或 Edge。");
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
const stopListening = () => {
|
||||
// 仅在识别引擎正在运行时尝试停止
|
||||
// 不要在这里重置 isListening 和 isStartingRef,等待 onend 回调
|
||||
if (isListening) {
|
||||
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;
|
||||
}
|
||||
}
|
||||
};
|
||||
@@ -78,15 +172,18 @@ const VoiceInput: React.FC<VoiceInputProps> = ({ onTranscript, isProcessing, isF
|
||||
<button
|
||||
onPointerDown={startListening}
|
||||
onPointerUp={stopListening}
|
||||
onPointerLeave={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'
|
||||
? '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' : ''}`}
|
||||
>
|
||||
@@ -113,4 +210,4 @@ const VoiceInput: React.FC<VoiceInputProps> = ({ onTranscript, isProcessing, isF
|
||||
);
|
||||
};
|
||||
|
||||
export default VoiceInput;
|
||||
export default VoiceInput;
|
||||
|
||||
Reference in New Issue
Block a user