Files
app_03_pregnancy/frontend/src/app/pages/AIChatPage.tsx
Karlcarlo 409dabafb5 feat: 初始化孕育无忧全栈项目基础代码结构
完成项目初始搭建,包含后端FastAPI+MongoDB服务、前端React+Vite项目的完整基础结构,添加各类配置文件与依赖清单,涵盖核心业务初始框架。
2026-05-21 00:46:48 +08:00

271 lines
9.1 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
import { useState, useRef, useEffect } from "react";
import { useNavigate, useParams } from "react-router";
import { motion, AnimatePresence } from "motion/react";
import {
ChevronLeft,
Send,
Sparkles,
Lightbulb,
User,
Bot,
} from "lucide-react";
import { Button } from "../components/ui/button";
import { Input } from "../components/ui/input";
import { Card } from "../components/ui/card";
import { sendChatMessage, getChatHistory } from "../services/api";
interface Message {
id: string;
role: "user" | "assistant";
content: string;
timestamp: Date;
}
export function AIChatPage() {
const navigate = useNavigate();
const { reportId } = useParams();
const [messages, setMessages] = useState<Message[]>([
{
id: "1",
role: "assistant",
content:
"您好!我是您的孕期健康助手。您可以向我询问报告中的任何指标,我会结合医学知识为您详细解答。",
timestamp: new Date(),
},
]);
const [input, setInput] = useState("");
const [isTyping, setIsTyping] = useState(false);
const [quickQuestions, setQuickQuestions] = useState<string[]>([
"这个指标偏高对宝宝有影响吗?",
"我需要补充什么营养?",
"下次检查需要注意什么?",
"这种情况多久会恢复正常?",
]);
const messagesEndRef = useRef<HTMLDivElement>(null);
// Load chat history on mount
useEffect(() => {
if (!reportId) return;
getChatHistory(reportId).then((data) => {
if (data.messages.length > 0) {
setMessages(
data.messages.map((m, i) => ({
id: String(i),
role: m.role as "user" | "assistant",
content: m.content,
timestamp: new Date(m.timestamp),
}))
);
}
if (data.quick_questions.length > 0) {
setQuickQuestions(data.quick_questions);
}
}).catch(() => {/* keep defaults */});
}, [reportId]);
const scrollToBottom = () => {
messagesEndRef.current?.scrollIntoView({ behavior: "smooth" });
};
useEffect(() => {
scrollToBottom();
}, [messages]);
const handleSend = async () => {
if (!input.trim() || !reportId) return;
const userMessage: Message = {
id: Date.now().toString(),
role: "user",
content: input,
timestamp: new Date(),
};
setMessages((prev) => [...prev, userMessage]);
const question = input;
setInput("");
setIsTyping(true);
try {
const result = await sendChatMessage(reportId, question);
const aiResponse: Message = {
id: (Date.now() + 1).toString(),
role: "assistant",
content: result.ai_message.content,
timestamp: new Date(result.ai_message.timestamp),
};
setMessages((prev) => [...prev, aiResponse]);
} catch {
const errorMsg: Message = {
id: (Date.now() + 1).toString(),
role: "assistant",
content: "抱歉,AI 服务暂时不可用,请稍后重试。",
timestamp: new Date(),
};
setMessages((prev) => [...prev, errorMsg]);
} finally {
setIsTyping(false);
}
};
const handleQuickQuestion = (question: string) => {
setInput(question);
};
return (
<div className="min-h-dvh bg-morandi-50 flex flex-col">
{/* 顶部导航 */}
<div className="bg-white border-b border-gray-100 sticky top-0 z-10">
<div className="mx-auto px-4 py-4 flex items-center justify-between">
<div className="flex items-center gap-3">
<button
onClick={() => navigate(-1)}
className="w-10 h-10 rounded-full hover:bg-gray-100 flex items-center justify-center"
>
<ChevronLeft className="w-6 h-6 text-gray-600" />
</button>
<div className="flex items-center gap-2">
<div className="w-10 h-10 bg-morandi-400 rounded-full flex items-center justify-center">
<Sparkles className="w-5 h-5 text-white" />
</div>
<div>
<h1 className="font-semibold text-gray-800">AI 健康顾问</h1>
<p className="text-xs text-gray-500">智能解答,随时陪伴</p>
</div>
</div>
</div>
</div>
</div>
{/* 消息列表 */}
<div className="flex-1 overflow-y-auto">
<div className="mx-auto px-4 py-6 space-y-4">
<AnimatePresence>
{messages.map((message) => (
<motion.div
key={message.id}
initial={{ opacity: 0, y: 10 }}
animate={{ opacity: 1, y: 0 }}
exit={{ opacity: 0, y: -10 }}
className={`flex gap-3 ${
message.role === "user" ? "flex-row-reverse" : ""
}`}
>
<div
className={`w-10 h-10 rounded-full flex items-center justify-center flex-shrink-0 ${
message.role === "user"
? "bg-morandi-100"
: "bg-morandi-400"
}`}
>
{message.role === "user" ? (
<User className="w-5 h-5 text-morandi-500" />
) : (
<Bot className="w-5 h-5 text-white" />
)}
</div>
<Card
className={`max-w-[75%] p-4 rounded-2xl border-0 ${
message.role === "user"
? "bg-morandi-400 text-white"
: "bg-white shadow-sm"
}`}
>
<p
className={`text-sm leading-relaxed whitespace-pre-line ${
message.role === "user" ? "text-white" : "text-gray-700"
}`}
>
{message.content}
</p>
<p
className={`text-xs mt-2 ${
message.role === "user"
? "text-morandi-100"
: "text-gray-400"
}`}
>
{message.timestamp.toLocaleTimeString("zh-CN", {
hour: "2-digit",
minute: "2-digit",
})}
</p>
</Card>
</motion.div>
))}
</AnimatePresence>
{isTyping && (
<motion.div
initial={{ opacity: 0 }}
animate={{ opacity: 1 }}
className="flex gap-3"
>
<div className="w-10 h-10 bg-morandi-400 rounded-full flex items-center justify-center">
<Bot className="w-5 h-5 text-white" />
</div>
<Card className="bg-white shadow-sm p-4 rounded-2xl">
<div className="flex gap-1">
<div className="w-2 h-2 bg-gray-400 rounded-full animate-bounce"></div>
<div
className="w-2 h-2 bg-gray-400 rounded-full animate-bounce"
style={{ animationDelay: "0.1s" }}
></div>
<div
className="w-2 h-2 bg-gray-400 rounded-full animate-bounce"
style={{ animationDelay: "0.2s" }}
></div>
</div>
</Card>
</motion.div>
)}
<div ref={messagesEndRef} />
</div>
</div>
{/* 快捷提问 */}
{messages.length <= 1 && (
<div className="mx-auto px-4 pb-4">
<div className="flex items-center gap-2 mb-3">
<Lightbulb className="w-4 h-4 text-morandi-400" />
<p className="text-sm text-gray-600">试试这样问:</p>
</div>
<div className="grid grid-cols-2 gap-2">
{quickQuestions.map((question, index) => (
<button
key={index}
onClick={() => handleQuickQuestion(question)}
className="bg-white rounded-xl p-3 text-sm text-left text-gray-600 hover:bg-morandi-50 hover:text-morandi-500 transition-colors border border-gray-100"
>
{question}
</button>
))}
</div>
</div>
)}
{/* 输入框 */}
<div className="bg-white border-t border-gray-100 sticky bottom-0">
<div className="mx-auto px-4 py-4">
<div className="flex gap-3">
<Input
value={input}
onChange={(e) => setInput(e.target.value)}
onKeyPress={(e) => e.key === "Enter" && handleSend()}
placeholder="输入您的问题..."
className="flex-1 rounded-full border-gray-200 focus:border-morandi-200"
/>
<Button
onClick={handleSend}
disabled={!input.trim() || isTyping}
className="w-12 h-12 rounded-full bg-morandi-400 hover:from-morandi-500 hover:to-morandi-500 disabled:opacity-50"
>
<Send className="w-5 h-5 text-white" />
</Button>
</div>
</div>
</div>
</div>
);
}