From d4aa67b97d7394576ceee78b661a1f51a1192fb4 Mon Sep 17 00:00:00 2001 From: karlcarlo Date: Fri, 23 Jan 2026 11:15:25 +0800 Subject: [PATCH] =?UTF-8?q?refactor(api):=20=E7=B2=BE=E7=AE=80=E5=B9=B6?= =?UTF-8?q?=E7=A7=BB=E9=99=A4=E5=A4=9A=E4=BD=99=E7=9A=84API=E6=8E=A5?= =?UTF-8?q?=E5=8F=A3=E5=92=8C=E7=B1=BB=E5=9E=8B=E5=AE=9A=E4=B9=89?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - 移除多余的统计、预算、AI设置、用户偏好、数据同步及AI处理相关类型定义 - 删除大量未使用的API请求函数,简化导出接口 - 保留核心的用户认证、交易记录、聊天消息和账单提醒相关接口 - 优化App组件状态初始化,改为从localStorage加载持久化数据,避免重复请求 - VoiceInput组件重构,改用持续监听及更健壮的权限和错误处理逻辑 - 优化语音识别启动和停止机制,提升用户体验和错误恢复能力 - 更新依赖顺序,无功能性变更 --- .gitignore | 4 +- App.tsx | 78 ++++++--- components/VoiceInput.tsx | 169 +++++++++++++++---- package.json | 6 +- services/api.ts | 338 -------------------------------------- 5 files changed, 193 insertions(+), 402 deletions(-) diff --git a/.gitignore b/.gitignore index fe3ae21..8223605 100644 --- a/.gitignore +++ b/.gitignore @@ -24,4 +24,6 @@ dist-ssr *.sw? .qoder -.trae \ No newline at end of file +.trae + +*.tar \ No newline at end of file diff --git a/App.tsx b/App.tsx index cd543b2..d10e764 100644 --- a/App.tsx +++ b/App.tsx @@ -5,7 +5,6 @@ import LedgerPage from './components/LedgerPage'; import StatisticsPage from './components/StatisticsPage'; import EditTransactionModal from './components/EditTransactionModal'; import { Transaction, TransactionType, Message, ExpenseCategory, IncomeCategory, BillReminder } from './types'; -import { v4 as uuidv4 } from 'uuid'; import { deleteMessage, deleteTransaction, updateTransaction, createTransaction, createMessage, getMessages, getTransactions, loginOrRegister, getUserProfile, createReminder, getReminders, updateReminder, toggleReminder, deleteReminder @@ -84,10 +83,40 @@ const getCategoryIcon = (category: string, type: TransactionType) => { const App: React.FC = () => { const [activeTab, setActiveTab] = useState('chat'); - const [messages, setMessages] = useState([]); - const [transactions, setTransactions] = useState([]); - const [budgets, setBudgets] = useState>({ '总预算': 3000 }); - const [reminders, setReminders] = useState([]); + const [messages, setMessages] = useState(() => { + try { + const saved = localStorage.getItem('cui_messages'); + return saved ? JSON.parse(saved) : []; + } catch (e) { + return []; + } + }); + const [transactions, setTransactions] = useState(() => { + try { + const saved = localStorage.getItem('cui_transactions'); + return saved ? JSON.parse(saved) : []; + } catch (e) { + return []; + } + }); + const [budgets, setBudgets] = useState>(() => { + try { + const saved = localStorage.getItem('cui_budget'); + if (saved) { + const parsed = JSON.parse(saved); + return typeof parsed === 'number' ? { '总预算': parsed } : parsed; + } + } catch (e) {} + return { '总预算': 3000 }; + }); + const [reminders, setReminders] = useState(() => { + try { + const saved = localStorage.getItem('cui_reminders'); + return saved ? JSON.parse(saved) : []; + } catch (e) { + return []; + } + }); const [inputText, setInputText] = useState(''); const [isProcessing, setIsProcessing] = useState(false); const [editingTransaction, setEditingTransaction] = useState(null); @@ -96,9 +125,23 @@ const App: React.FC = () => { const [token, setToken] = useState(null); const [userInfo, setUserInfo] = useState(null); - const [aiSettings, setAiSettings] = useState(DEFAULT_AI_SETTINGS); + const [aiSettings, setAiSettings] = useState(() => { + try { + const saved = localStorage.getItem('cui_ai_settings'); + return saved ? JSON.parse(saved) : DEFAULT_AI_SETTINGS; + } catch (e) { + return DEFAULT_AI_SETTINGS; + } + }); const [isSettingsOpen, setIsSettingsOpen] = useState(false); - const [customPreset, setCustomPreset] = useState({ name: '自定义', description: '可以根据你喜欢的方式设定助手的语气和风格' }); + const [customPreset, setCustomPreset] = useState(() => { + try { + const saved = localStorage.getItem('cui_custom_preset_info'); + return saved ? JSON.parse(saved) : { name: '自定义', description: '可以根据你喜欢的方式设定助手的语气和风格' }; + } catch (e) { + return { name: '自定义', description: '可以根据你喜欢的方式设定助手的语气和风格' }; + } + }); const fileInputRef = useRef(null); const avatarInputRef = useRef(null); @@ -177,19 +220,6 @@ const App: React.FC = () => { }; const loadInitialData = async () => { - const storedBudget = localStorage.getItem('cui_budget'); - const storedReminders = localStorage.getItem('cui_reminders'); - const storedAiSettings = localStorage.getItem('cui_ai_settings'); - const storedCustom = localStorage.getItem('cui_custom_preset_info'); - - if (storedBudget) { - const parsed = JSON.parse(storedBudget); - setBudgets(typeof parsed === 'number' ? { '总预算': parsed } : parsed); - } - if (storedReminders) setReminders(JSON.parse(storedReminders)); - if (storedAiSettings) setAiSettings(JSON.parse(storedAiSettings)); - if (storedCustom) setCustomPreset(JSON.parse(storedCustom)); - // 从数据库获取交易列表 try { const response = await getTransactions(); @@ -238,7 +268,7 @@ const App: React.FC = () => { console.log('消息列表已从数据库加载:', messagesFromDB.length); } else { // 如果数据库中没有消息,显示初始欢迎消息 - const aiName = storedAiSettings ? JSON.parse(storedAiSettings).name : '崔经理'; + const aiName = aiSettings.name; setMessages([{ id: 'init', role: 'assistant', @@ -254,7 +284,7 @@ const App: React.FC = () => { setMessages(JSON.parse(storedMessages)); } else { // 显示初始欢迎消息 - const aiName = storedAiSettings ? JSON.parse(storedAiSettings).name : '崔经理'; + const aiName = aiSettings.name; setMessages([{ id: 'init', role: 'assistant', @@ -452,7 +482,7 @@ const App: React.FC = () => { } }; - const handleSendMessage = async (text: string, imageBase64?: string, mimeType?: string) => { + const handleSendMessage = React.useCallback(async (text: string, imageBase64?: string, mimeType?: string) => { if (!text.trim() && !imageBase64) return; // 先保存用户消息到数据库 @@ -542,7 +572,7 @@ const App: React.FC = () => { } finally { setIsProcessing(false); } - }; + }, [aiSettings, transactions, messages]); const handleKeyPress = (e: React.KeyboardEvent) => { if (e.key === 'Enter' && !e.shiftKey) { diff --git a/components/VoiceInput.tsx b/components/VoiceInput.tsx index 5e8c266..da3aa65 100644 --- a/components/VoiceInput.tsx +++ b/components/VoiceInput.tsx @@ -9,66 +9,160 @@ interface VoiceInputProps { const VoiceInput: React.FC = ({ onTranscript, isProcessing, isFullWidth = false }) => { const [isListening, setIsListening] = useState(false); const recognitionRef = useRef(null); - const timerRef = useRef(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 = ({ onTranscript, isProcessing, isF