feat: 添加聊天功能及相关路由配置和UI组件,并引入 react-icons 依赖。

This commit is contained in:
2026-01-29 22:00:30 +08:00
parent 6087b35ce5
commit 08048ec3b6
6 changed files with 796 additions and 0 deletions

402
src/pages/Chat/Chat.css Normal file
View File

@@ -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);
}

377
src/pages/Chat/Chat.tsx Normal file
View File

@@ -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<Message[]>([]);
const [inputText, setInputText] = useState('');
const [isLoading, setIsLoading] = useState(false);
const [isRecording, setIsRecording] = useState(false);
const [sessionId, setSessionId] = useState<string | null>(null);
const messagesEndRef = useRef<HTMLDivElement>(null);
const inputRef = useRef<HTMLTextAreaElement>(null);
const mediaRecorderRef = useRef<MediaRecorder | null>(null);
const audioChunksRef = useRef<Blob[]>([]);
// 滚动到底部
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 (
<div className="chat-page">
{/* Header */}
<header className="chat-header">
<button className="chat-header-back" onClick={() => navigate(-1)}>
<FiArrowLeft size={20} />
</button>
<div className="chat-header-avatar">🪙</div>
<div className="chat-header-info">
<div className="chat-header-name"></div>
<div className="chat-header-status"></div>
</div>
</header>
{/* Messages */}
<div className="chat-messages">
{messages.length === 0 ? (
<div className="chat-empty">
<div className="chat-empty-icon">🪙</div>
<div className="chat-empty-title"></div>
<div className="chat-empty-desc">
</div>
<div className="chat-empty-suggestions">
<button
className="chat-suggestion-chip"
onClick={() => handleSuggestion('想喝杯奶茶15块')}
>
15
</button>
<button
className="chat-suggestion-chip"
onClick={() => handleSuggestion('中午点了外卖 35元')}
>
35
</button>
<button
className="chat-suggestion-chip"
onClick={() => handleSuggestion('想买个耳机 299')}
>
299
</button>
</div>
</div>
) : (
<>
{messages.map((msg) => (
<div key={msg.id} className={`chat-message ${msg.role}`}>
<div className="chat-message-avatar">
{msg.role === 'assistant' ? '🪙' : '👤'}
</div>
<div>
<div className="chat-message-content">{msg.content}</div>
{msg.confirmationCard && (
<div className="confirmation-card">
<div className="confirmation-card-header">
<span className="confirmation-card-icon">📝</span>
</div>
<div className="confirmation-card-fields">
<div className="confirmation-card-field">
<div className="confirmation-card-label"></div>
<div className={`confirmation-card-value amount ${msg.confirmationCard.type === 'income' ? 'income' : ''}`}>
{msg.confirmationCard.type === 'income' ? '+' : '-'}
¥{msg.confirmationCard.amount.toFixed(2)}
</div>
</div>
<div className="confirmation-card-field">
<div className="confirmation-card-label"></div>
<div className="confirmation-card-value">
{msg.confirmationCard.type === 'income' ? '收入' : '支出'}
</div>
</div>
<div className="confirmation-card-field">
<div className="confirmation-card-label"></div>
<div className="confirmation-card-value">{msg.confirmationCard.category}</div>
</div>
<div className="confirmation-card-field">
<div className="confirmation-card-label"></div>
<div className="confirmation-card-value">{msg.confirmationCard.account}</div>
</div>
{msg.confirmationCard.note && (
<div className="confirmation-card-field full-width">
<div className="confirmation-card-label"></div>
<div className="confirmation-card-value">{msg.confirmationCard.note}</div>
</div>
)}
</div>
<div className="confirmation-card-actions">
<button
className="confirmation-card-btn cancel"
onClick={handleCancel}
disabled={isLoading}
>
</button>
<button
className="confirmation-card-btn confirm"
onClick={() => handleConfirm(msg.confirmationCard!)}
disabled={isLoading}
>
</button>
</div>
</div>
)}
</div>
</div>
))}
{isLoading && (
<div className="chat-message assistant">
<div className="chat-message-avatar">🪙</div>
<div className="chat-loading">
<div className="chat-loading-dots">
<div className="chat-loading-dot"></div>
<div className="chat-loading-dot"></div>
<div className="chat-loading-dot"></div>
</div>
</div>
</div>
)}
<div ref={messagesEndRef} />
</>
)}
</div>
{/* Input Area */}
<div className="chat-input-area">
<div className="chat-input-container">
<textarea
ref={inputRef}
className="chat-input"
placeholder="说说你想买什么..."
value={inputText}
onChange={(e) => setInputText(e.target.value)}
onKeyDown={handleKeyDown}
rows={1}
disabled={isLoading}
/>
<button
className={`chat-input-btn ${isRecording ? 'recording' : ''}`}
onClick={handleVoiceToggle}
disabled={isLoading}
>
{isRecording ? <FiMicOff size={20} /> : <FiMic size={20} />}
</button>
<button
className="chat-input-btn send"
onClick={handleSend}
disabled={!inputText.trim() || isLoading}
>
<FiSend size={18} />
</button>
</div>
</div>
</div>
);
}

1
src/pages/Chat/index.ts Normal file
View File

@@ -0,0 +1 @@
export { default } from './Chat';

View File

@@ -17,6 +17,7 @@ import Profile from '../pages/Profile';
import GitHubCallback from '../pages/GitHubCallback';
import GiteeCallback from '../pages/GiteeCallback';
import Notifications from '../pages/Notifications';
import Chat from '../pages/Chat';
import Layout from '../components/common/Layout';
import ProtectedRoute from '../components/common/ProtectedRoute';
@@ -53,6 +54,10 @@ export const router = createBrowserRouter([
path: 'home',
element: <Home />,
},
{
path: 'chat',
element: <Chat />,
},
{
path: 'transactions',
element: <Transactions />,