diff --git a/package-lock.json b/package-lock.json index 3d0c802..a4a4891 100644 --- a/package-lock.json +++ b/package-lock.json @@ -21,6 +21,7 @@ "react": "^19.2.0", "react-confetti": "^6.4.0", "react-dom": "^19.2.0", + "react-icons": "^5.5.0", "react-router-dom": "^7.12.0", "react-use": "^17.6.0" }, @@ -4689,6 +4690,15 @@ "react": "^19.2.3" } }, + "node_modules/react-icons": { + "version": "5.5.0", + "resolved": "https://registry.npmjs.org/react-icons/-/react-icons-5.5.0.tgz", + "integrity": "sha512-MEFcXdkP3dLo8uumGI5xN3lDFNsRtrjbOEKDLD7yv76v4wpnEq2Lt2qeHaQOr34I/wPN3s3+N08WkQ+CW37Xiw==", + "license": "MIT", + "peerDependencies": { + "react": "*" + } + }, "node_modules/react-is": { "version": "17.0.2", "resolved": "https://registry.npmjs.org/react-is/-/react-is-17.0.2.tgz", diff --git a/package.json b/package.json index d08ce98..fa5d7fc 100644 --- a/package.json +++ b/package.json @@ -29,6 +29,7 @@ "react": "^19.2.0", "react-confetti": "^6.4.0", "react-dom": "^19.2.0", + "react-icons": "^5.5.0", "react-router-dom": "^7.12.0", "react-use": "^17.6.0" }, diff --git a/src/pages/Chat/Chat.css b/src/pages/Chat/Chat.css new file mode 100644 index 0000000..66b2e50 --- /dev/null +++ b/src/pages/Chat/Chat.css @@ -0,0 +1,402 @@ +.chat-page { + display: flex; + flex-direction: column; + height: 100vh; + background: var(--bg-primary); +} + +.chat-header { + display: flex; + align-items: center; + gap: 12px; + padding: 16px 20px; + background: var(--bg-secondary); + border-bottom: 1px solid var(--border-primary); +} + +.chat-header-back { + background: none; + border: none; + color: var(--text-secondary); + cursor: pointer; + padding: 8px; + border-radius: 8px; + display: flex; + align-items: center; + justify-content: center; + transition: all 0.2s; +} + +.chat-header-back:hover { + background: var(--bg-tertiary); + color: var(--text-primary); +} + +.chat-header-avatar { + width: 40px; + height: 40px; + border-radius: 50%; + background: linear-gradient(135deg, #f59e0b, #d97706); + display: flex; + align-items: center; + justify-content: center; + font-size: 20px; +} + +.chat-header-info { + flex: 1; +} + +.chat-header-name { + font-size: 16px; + font-weight: 600; + color: var(--text-primary); +} + +.chat-header-status { + font-size: 12px; + color: var(--text-tertiary); +} + +/* Message List */ +.chat-messages { + flex: 1; + overflow-y: auto; + padding: 16px 20px; + display: flex; + flex-direction: column; + gap: 16px; +} + +.chat-message { + display: flex; + gap: 12px; + max-width: 85%; +} + +.chat-message.user { + flex-direction: row-reverse; + align-self: flex-end; +} + +.chat-message.assistant { + align-self: flex-start; +} + +.chat-message-avatar { + width: 32px; + height: 32px; + border-radius: 50%; + flex-shrink: 0; + display: flex; + align-items: center; + justify-content: center; + font-size: 14px; +} + +.chat-message.assistant .chat-message-avatar { + background: linear-gradient(135deg, #f59e0b, #d97706); +} + +.chat-message.user .chat-message-avatar { + background: var(--color-primary); + color: white; +} + +.chat-message-content { + padding: 12px 16px; + border-radius: 16px; + font-size: 14px; + line-height: 1.5; + white-space: pre-wrap; +} + +.chat-message.assistant .chat-message-content { + background: var(--bg-secondary); + color: var(--text-primary); + border-bottom-left-radius: 4px; +} + +.chat-message.user .chat-message-content { + background: var(--color-primary); + color: white; + border-bottom-right-radius: 4px; +} + +/* Confirmation Card */ +.confirmation-card { + background: var(--bg-secondary); + border: 1px solid var(--border-primary); + border-radius: 16px; + padding: 16px; + margin-top: 8px; +} + +.confirmation-card-header { + display: flex; + align-items: center; + gap: 8px; + margin-bottom: 12px; + font-size: 14px; + font-weight: 600; + color: var(--text-primary); +} + +.confirmation-card-icon { + font-size: 18px; +} + +.confirmation-card-fields { + display: grid; + grid-template-columns: 1fr 1fr; + gap: 12px; + margin-bottom: 16px; +} + +.confirmation-card-field { + display: flex; + flex-direction: column; + gap: 4px; +} + +.confirmation-card-field.full-width { + grid-column: 1 / -1; +} + +.confirmation-card-label { + font-size: 12px; + color: var(--text-tertiary); +} + +.confirmation-card-value { + font-size: 14px; + font-weight: 500; + color: var(--text-primary); +} + +.confirmation-card-value.amount { + font-size: 20px; + font-weight: 700; + color: var(--color-expense); +} + +.confirmation-card-value.amount.income { + color: var(--color-income); +} + +.confirmation-card-actions { + display: flex; + gap: 12px; +} + +.confirmation-card-btn { + flex: 1; + padding: 12px; + border-radius: 12px; + font-size: 14px; + font-weight: 600; + cursor: pointer; + transition: all 0.2s; + border: none; +} + +.confirmation-card-btn.confirm { + background: var(--color-primary); + color: white; +} + +.confirmation-card-btn.confirm:hover { + opacity: 0.9; +} + +.confirmation-card-btn.cancel { + background: var(--bg-tertiary); + color: var(--text-secondary); +} + +.confirmation-card-btn.cancel:hover { + background: var(--bg-quaternary); +} + +/* Input Area */ +.chat-input-area { + padding: 12px 20px 24px; + background: var(--bg-secondary); + border-top: 1px solid var(--border-primary); +} + +.chat-input-container { + display: flex; + align-items: flex-end; + gap: 12px; + background: var(--bg-primary); + border-radius: 24px; + padding: 8px 16px; + border: 1px solid var(--border-primary); +} + +.chat-input { + flex: 1; + background: none; + border: none; + outline: none; + font-size: 14px; + color: var(--text-primary); + resize: none; + max-height: 120px; + min-height: 24px; + line-height: 24px; +} + +.chat-input::placeholder { + color: var(--text-tertiary); +} + +.chat-input-btn { + background: none; + border: none; + cursor: pointer; + padding: 8px; + border-radius: 50%; + display: flex; + align-items: center; + justify-content: center; + transition: all 0.2s; + color: var(--text-tertiary); +} + +.chat-input-btn:hover { + background: var(--bg-tertiary); + color: var(--text-primary); +} + +.chat-input-btn.send { + background: var(--color-primary); + color: white; +} + +.chat-input-btn.send:hover { + opacity: 0.9; +} + +.chat-input-btn.send:disabled { + opacity: 0.5; + cursor: not-allowed; +} + +.chat-input-btn.recording { + background: #ef4444; + color: white; + animation: pulse 1s infinite; +} + +@keyframes pulse { + + 0%, + 100% { + transform: scale(1); + } + + 50% { + transform: scale(1.1); + } +} + +/* Loading */ +.chat-loading { + display: flex; + align-items: center; + gap: 8px; + padding: 12px 16px; + background: var(--bg-secondary); + border-radius: 16px; + border-bottom-left-radius: 4px; + max-width: 120px; +} + +.chat-loading-dots { + display: flex; + gap: 4px; +} + +.chat-loading-dot { + width: 8px; + height: 8px; + border-radius: 50%; + background: var(--text-tertiary); + animation: bounce 1.4s infinite ease-in-out; +} + +.chat-loading-dot:nth-child(1) { + animation-delay: -0.32s; +} + +.chat-loading-dot:nth-child(2) { + animation-delay: -0.16s; +} + +@keyframes bounce { + + 0%, + 80%, + 100% { + transform: scale(0); + } + + 40% { + transform: scale(1); + } +} + +/* Empty State */ +.chat-empty { + flex: 1; + display: flex; + flex-direction: column; + align-items: center; + justify-content: center; + gap: 16px; + padding: 40px; +} + +.chat-empty-icon { + font-size: 64px; +} + +.chat-empty-title { + font-size: 18px; + font-weight: 600; + color: var(--text-primary); +} + +.chat-empty-desc { + font-size: 14px; + color: var(--text-tertiary); + text-align: center; + max-width: 280px; +} + +.chat-empty-suggestions { + display: flex; + flex-wrap: wrap; + gap: 8px; + justify-content: center; + margin-top: 8px; +} + +.chat-suggestion-chip { + padding: 8px 16px; + background: var(--bg-secondary); + border: 1px solid var(--border-primary); + border-radius: 20px; + font-size: 13px; + color: var(--text-secondary); + cursor: pointer; + transition: all 0.2s; +} + +.chat-suggestion-chip:hover { + background: var(--bg-tertiary); + color: var(--text-primary); + border-color: var(--color-primary); +} \ No newline at end of file diff --git a/src/pages/Chat/Chat.tsx b/src/pages/Chat/Chat.tsx new file mode 100644 index 0000000..98a024e --- /dev/null +++ b/src/pages/Chat/Chat.tsx @@ -0,0 +1,377 @@ +import { useState, useRef, useEffect } from 'react'; +import { useNavigate } from 'react-router-dom'; +import { FiArrowLeft, FiSend, FiMic, FiMicOff } from 'react-icons/fi'; +import aiService from '../../services/aiService'; +import type { AIChatResponse, ConfirmationCard } from '../../types'; +import './Chat.css'; + +interface Message { + id: string; + role: 'user' | 'assistant'; + content: string; + confirmationCard?: ConfirmationCard; + timestamp: Date; +} + +export default function Chat() { + const navigate = useNavigate(); + const [messages, setMessages] = useState([]); + const [inputText, setInputText] = useState(''); + const [isLoading, setIsLoading] = useState(false); + const [isRecording, setIsRecording] = useState(false); + const [sessionId, setSessionId] = useState(null); + + const messagesEndRef = useRef(null); + const inputRef = useRef(null); + const mediaRecorderRef = useRef(null); + const audioChunksRef = useRef([]); + + // 滚动到底部 + const scrollToBottom = () => { + messagesEndRef.current?.scrollIntoView({ behavior: 'smooth' }); + }; + + useEffect(() => { + scrollToBottom(); + }, [messages]); + + // 发送消息 + const handleSend = async () => { + if (!inputText.trim() || isLoading) return; + + const userMessage: Message = { + id: Date.now().toString(), + role: 'user', + content: inputText.trim(), + timestamp: new Date(), + }; + + setMessages(prev => [...prev, userMessage]); + setInputText(''); + setIsLoading(true); + + try { + const response: AIChatResponse = await aiService.sendChatMessage( + userMessage.content, + sessionId || undefined + ); + + setSessionId(response.sessionId); + + const assistantMessage: Message = { + id: (Date.now() + 1).toString(), + role: 'assistant', + content: response.message, + confirmationCard: response.confirmationCard, + timestamp: new Date(), + }; + + setMessages(prev => [...prev, assistantMessage]); + } catch (error) { + console.error('Failed to send message:', error); + const errorMessage: Message = { + id: (Date.now() + 1).toString(), + role: 'assistant', + content: '抱歉,我遇到了一些问题,请稍后再试。', + timestamp: new Date(), + }; + setMessages(prev => [...prev, errorMessage]); + } finally { + setIsLoading(false); + } + }; + + // 确认记账 + const handleConfirm = async (card: ConfirmationCard) => { + setIsLoading(true); + try { + await aiService.confirmTransaction(card); + + const confirmMessage: Message = { + id: Date.now().toString(), + role: 'assistant', + content: `✅ 已记录!${card.type === 'income' ? '收入' : '支出'} ${card.amount.toFixed(2)} 元\n分类:${card.category}\n账户:${card.account}`, + timestamp: new Date(), + }; + + setMessages(prev => { + // 移除最后一条消息的确认卡片 + const updated = prev.map((msg, idx) => { + if (idx === prev.length - 1 && msg.confirmationCard) { + return { ...msg, confirmationCard: undefined }; + } + return msg; + }); + return [...updated, confirmMessage]; + }); + + // 清除会话以开始新对话 + aiService.clearSession(); + setSessionId(null); + } catch (error) { + console.error('Failed to confirm transaction:', error); + } finally { + setIsLoading(false); + } + }; + + // 取消记账 + const handleCancel = () => { + setMessages(prev => { + const updated = prev.map((msg, idx) => { + if (idx === prev.length - 1 && msg.confirmationCard) { + return { ...msg, confirmationCard: undefined }; + } + return msg; + }); + return updated; + }); + + const cancelMessage: Message = { + id: Date.now().toString(), + role: 'assistant', + content: '好的,这笔账不记了。还有什么需要帮忙的吗?', + timestamp: new Date(), + }; + setMessages(prev => [...prev, cancelMessage]); + + aiService.clearSession(); + setSessionId(null); + }; + + // 语音录制 + const handleVoiceToggle = async () => { + if (isRecording) { + // 停止录音 + mediaRecorderRef.current?.stop(); + setIsRecording(false); + } else { + // 开始录音 + try { + const stream = await navigator.mediaDevices.getUserMedia({ audio: true }); + const mediaRecorder = new MediaRecorder(stream); + mediaRecorderRef.current = mediaRecorder; + audioChunksRef.current = []; + + mediaRecorder.ondataavailable = (event) => { + audioChunksRef.current.push(event.data); + }; + + mediaRecorder.onstop = async () => { + const audioBlob = new Blob(audioChunksRef.current, { type: 'audio/webm' }); + stream.getTracks().forEach(track => track.stop()); + + // 处理语音 + setIsLoading(true); + try { + const response = await aiService.processVoiceInput(audioBlob); + + // 添加用户消息(使用转录文本) + const userMessage: Message = { + id: Date.now().toString(), + role: 'user', + content: '🎤 [语音消息]', + timestamp: new Date(), + }; + setMessages(prev => [...prev, userMessage]); + + setSessionId(response.sessionId); + + const assistantMessage: Message = { + id: (Date.now() + 1).toString(), + role: 'assistant', + content: response.message, + confirmationCard: response.confirmationCard, + timestamp: new Date(), + }; + setMessages(prev => [...prev, assistantMessage]); + } catch (error) { + console.error('Voice processing failed:', error); + } finally { + setIsLoading(false); + } + }; + + mediaRecorder.start(); + setIsRecording(true); + } catch (error) { + console.error('Failed to start recording:', error); + } + } + }; + + // 处理快捷建议 + const handleSuggestion = (text: string) => { + setInputText(text); + inputRef.current?.focus(); + }; + + // 处理回车发送 + const handleKeyDown = (e: React.KeyboardEvent) => { + if (e.key === 'Enter' && !e.shiftKey) { + e.preventDefault(); + handleSend(); + } + }; + + return ( +
+ {/* Header */} +
+ +
🪙
+
+
小金
+
你的智能记账助手
+
+
+ + {/* Messages */} +
+ {messages.length === 0 ? ( +
+
🪙
+
我是小金,你的记账助手
+
+ 告诉我你想买什么、花了多少,我会帮你分析并记账 +
+
+ + + +
+
+ ) : ( + <> + {messages.map((msg) => ( +
+
+ {msg.role === 'assistant' ? '🪙' : '👤'} +
+
+
{msg.content}
+ + {msg.confirmationCard && ( +
+
+ 📝 + 确认记账 +
+
+
+
金额
+
+ {msg.confirmationCard.type === 'income' ? '+' : '-'} + ¥{msg.confirmationCard.amount.toFixed(2)} +
+
+
+
类型
+
+ {msg.confirmationCard.type === 'income' ? '收入' : '支出'} +
+
+
+
分类
+
{msg.confirmationCard.category}
+
+
+
账户
+
{msg.confirmationCard.account}
+
+ {msg.confirmationCard.note && ( +
+
备注
+
{msg.confirmationCard.note}
+
+ )} +
+
+ + +
+
+ )} +
+
+ ))} + + {isLoading && ( +
+
🪙
+
+
+
+
+
+
+
+
+ )} + +
+ + )} +
+ + {/* Input Area */} +
+
+