Files
app_02_bill/components/VoiceInput.tsx
karlcarlo c06e74bd01 feat(app): 实现完整的财务管家记账聊天与统计功能
- 新增App主组件,实现聊天、账本、统计三大功能模块切换
- 支持语音及文本输入,自动解析记账信息并生成交易记录
- 交易记录支持编辑、删除、提醒等操作,数据本地持久化
- 管家性格预设和自定义设定,包含头像上传和语气调整
- 会话展示支持关联交易显示,样式区分收入与支出
- 账本页添加日期分组交易列表及饼图统计展示
- 消息列表实现渐变背景和交互动画,提升用户体验
- 支持图片上传并同步发送给AI进行解析记账
- 底部导航实现三标签切换并同步显示对应内容
- 全局事务编辑弹窗支持直接调用和删除操作
- 增加环境变量示例和.gitignore规则,完善工程配置
2026-01-19 20:20:54 +08:00

104 lines
3.7 KiB
TypeScript

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 timerRef = useRef<number | null>(null);
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);
setIsListening(false);
};
recognitionRef.current.onerror = (event: any) => {
console.error('Speech recognition error', event.error);
setIsListening(false);
};
recognitionRef.current.onend = () => {
setIsListening(false);
};
}
}, [onTranscript]);
const startListening = (e: React.MouseEvent | React.TouchEvent) => {
e.preventDefault();
if (isProcessing || isListening) return;
if (recognitionRef.current) {
try {
recognitionRef.current.start();
setIsListening(true);
} catch (e) {
console.error("Failed to start recognition", e);
}
} else {
alert("您的浏览器不支持语音识别,请使用 Chrome 或 Edge。");
}
};
const stopListening = () => {
if (isListening) {
recognitionRef.current?.stop();
setIsListening(false);
}
};
return (
<div className={`${isFullWidth ? 'w-full' : ''} relative select-none touch-none`}>
<button
onMouseDown={startListening}
onMouseUp={stopListening}
onMouseLeave={stopListening}
onTouchStart={startListening}
onTouchEnd={stopListening}
disabled={isProcessing}
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-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;