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

721 lines
30 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, useEffect } from "react";
import { useNavigate, useParams } from "react-router";
import { motion, AnimatePresence } from "motion/react";
import {
ChevronLeft,
Heart,
TrendingUp,
AlertCircle,
CheckCircle2,
Info,
Calendar,
Share2,
Download,
Sparkles,
Activity,
Baby,
MessageCircle,
FileText,
X,
Link2,
Check,
AlertTriangle,
ShieldAlert,
Loader2,
Image as ImageIcon,
} from "lucide-react";
import { Button } from "../components/ui/button";
import { Card } from "../components/ui/card";
import { Badge } from "../components/ui/badge";
import { generateAnalysis, type AnalysisResponse } from "../services/api";
export function AIAnalysisPage() {
const navigate = useNavigate();
const { reportId } = useParams();
const [showShareSheet, setShowShareSheet] = useState(false);
const [linkCopied, setLinkCopied] = useState(false);
const [loading, setLoading] = useState(true);
const [error, setError] = useState<string | null>(null);
const [analysisData, setAnalysisData] = useState<AnalysisResponse | null>(null);
useEffect(() => {
if (!reportId) return;
setLoading(true);
generateAnalysis(reportId)
.then((data) => setAnalysisData(data))
.catch((err) => setError(err instanceof Error ? err.message : "加载失败"))
.finally(() => setLoading(false));
}, [reportId]);
const handleCopyLink = async () => {
try {
const shareUrl = `${window.location.origin}/share/${reportId}`;
await navigator.clipboard.writeText(shareUrl);
setLinkCopied(true);
setTimeout(() => setLinkCopied(false), 2000);
} catch (error) {
console.error('复制失败:', error);
// 降级方案:使用旧的复制方法
const textArea = document.createElement('textarea');
textArea.value = `${window.location.origin}/share/${reportId}`;
textArea.style.position = 'fixed';
textArea.style.left = '-999999px';
document.body.appendChild(textArea);
textArea.select();
try {
document.execCommand('copy');
setLinkCopied(true);
setTimeout(() => setLinkCopied(false), 2000);
} catch (err) {
alert('复制失败,请手动复制链接');
}
document.body.removeChild(textArea);
}
};
if (loading) {
return (
<div className="min-h-dvh bg-morandi-50 flex items-center justify-center">
<div className="text-center">
<Loader2 className="w-12 h-12 text-morandi-400 animate-spin mx-auto mb-4" />
<p className="text-gray-600 font-medium">AI 正在解读您的报告...</p>
<p className="text-gray-400 text-sm mt-2">这可能需要几秒钟</p>
</div>
</div>
);
}
if (error || !analysisData) {
return (
<div className="min-h-dvh bg-morandi-50 flex items-center justify-center">
<div className="text-center px-8">
<AlertCircle className="w-12 h-12 text-red-400 mx-auto mb-4" />
<p className="text-gray-800 font-medium mb-2">解读失败</p>
<p className="text-gray-500 text-sm mb-4">{error || "未知错误"}</p>
<Button onClick={() => navigate("/home")} variant="outline">返回首页</Button>
</div>
</div>
);
}
const getStatusColor = (status: string) => {
switch (status) {
case "normal":
return {
bg: "bg-morandi-sage-100",
border: "border-morandi-sage-200",
text: "text-morandi-sage-600",
badge: "bg-morandi-sage-100 text-morandi-sage-600",
icon: "text-morandi-sage-400",
};
case "attention":
return {
bg: "bg-morandi-rose-100",
border: "border-morandi-200",
text: "text-morandi-600",
badge: "bg-morandi-rose-100 text-morandi-600",
icon: "text-morandi-rose-400",
};
case "high":
return {
bg: "bg-red-50",
border: "border-red-200",
text: "text-red-700",
badge: "bg-red-100 text-red-700",
icon: "text-red-500",
};
default:
return {
bg: "bg-gray-50",
border: "border-gray-200",
text: "text-gray-700",
badge: "bg-gray-100 text-gray-700",
icon: "text-gray-500",
};
}
};
const statusColors = getStatusColor(analysisData.overall_status);
// 指标状态颜色(紫色系色温区分:冷调sage→中性purple→暖调rose)
const getIndicatorColor = (indicatorStatus: string) => {
switch (indicatorStatus) {
case "normal":
return {
bg: "bg-morandi-sage-100",
border: "border-morandi-sage-200",
text: "text-morandi-sage-600",
badge: "bg-morandi-sage-100 text-morandi-sage-600",
barBg: "bg-morandi-sage-200",
barFill: "bg-morandi-sage-400",
};
case "borderline":
return {
bg: "bg-morandi-100",
border: "border-morandi-200",
text: "text-morandi-600",
badge: "bg-morandi-200 text-morandi-700",
barBg: "bg-morandi-200",
barFill: "bg-morandi-400",
};
case "abnormal":
return {
bg: "bg-morandi-rose-100",
border: "border-morandi-rose-300",
text: "text-morandi-700",
badge: "bg-morandi-rose-100 text-morandi-rose-400",
barBg: "bg-morandi-rose-100",
barFill: "bg-morandi-rose-400",
};
default:
return {
bg: "bg-gray-50",
border: "border-gray-200",
text: "text-gray-700",
badge: "bg-gray-100 text-gray-700",
barBg: "bg-gray-100",
barFill: "bg-gray-400",
};
}
};
const getRiskLabel = (riskLevel: string) => {
switch (riskLevel) {
case "low":
return { label: "低风险", color: "bg-morandi-100 text-morandi-600" };
case "medium":
return { label: "中风险", color: "bg-morandi-rose-100 text-morandi-rose-400" };
case "high":
return { label: "高风险", color: "bg-morandi-rose-100 text-morandi-700" };
default:
return { label: "", color: "" };
}
};
return (
<div className="min-h-dvh bg-morandi-50">
{/* 顶部导航 */}
<div className="bg-white border-b border-gray-100 sticky top-0 z-20">
<div className="mx-auto px-4 py-4 flex items-center justify-between">
<button
onClick={() => navigate(-1)}
className="flex items-center text-gray-600 hover:text-gray-900"
>
<ChevronLeft className="w-5 h-5 mr-1" />
返回
</button>
<h1 className="font-semibold text-gray-800">AI 智能解读</h1>
<button
onClick={() => setShowShareSheet(true)}
className="p-2 hover:bg-gray-100 rounded-lg"
>
<Share2 className="w-5 h-5 text-gray-600" />
</button>
</div>
</div>
<div className="mx-auto p-4 space-y-6 pb-20">
{/* AI 解读标识 */}
<motion.div
initial={{ opacity: 0, y: 20 }}
animate={{ opacity: 1, y: 0 }}
className="flex items-center justify-center gap-2 text-morandi-500"
>
<Sparkles className="w-5 h-5" />
<span className="text-sm font-medium">AI 深度解读 · 个性化定制</span>
</motion.div>
{/* 整体评估卡片 */}
<motion.div
initial={{ opacity: 0, y: 20 }}
animate={{ opacity: 1, y: 0 }}
transition={{ delay: 0.1 }}
>
<Card
className={`${statusColors.bg} border-2 ${statusColors.border} rounded-3xl p-6 shadow-sm`}
>
<div className="flex items-start gap-4 mb-4">
<div
className={`w-16 h-16 ${statusColors.badge} rounded-2xl flex items-center justify-center flex-shrink-0`}
>
<Baby className={`w-8 h-8 ${statusColors.icon}`} />
</div>
<div className="flex-1">
<div className="flex items-center gap-2 mb-2">
<h2 className="text-xl font-semibold text-gray-800">
整体评估
</h2>
<Badge className={statusColors.badge}>
{analysisData.overall_status === "normal"
? "一切正常"
: analysisData.overall_status === "attention"
? "需要关注"
: "需要重视"}
</Badge>
</div>
<p className={`text-sm ${statusColors.text} mb-3 leading-relaxed`}>
{analysisData.emotional_summary || analysisData.overall_summary}
</p>
<div className="flex flex-wrap gap-3 text-xs text-gray-600">
<div className="flex items-center gap-1">
<Calendar className="w-3 h-3" />
<span>{new Date(analysisData.created_at).toLocaleDateString("zh-CN")}</span>
</div>
</div>
</div>
</div>
{/* 风险等级指示器 */}
<div className="bg-white/50 backdrop-blur-sm rounded-2xl p-4">
<div className="flex items-center justify-between mb-2">
<span className="text-sm font-medium text-gray-700">
综合风险评级
</span>
<span
className={`text-sm font-semibold ${
analysisData.risk_level === "low"
? "text-morandi-sage-500"
: analysisData.risk_level === "medium"
? "text-morandi-600"
: "text-red-600"
}`}
>
{analysisData.risk_level === "low"
? "低风险"
: analysisData.risk_level === "medium"
? "中风险"
: "高风险"}
</span>
</div>
<div className="h-2 bg-gray-200 rounded-full overflow-hidden">
<motion.div
initial={{ width: 0 }}
animate={{
width:
analysisData.risk_level === "low"
? "25%"
: analysisData.risk_level === "medium"
? "60%"
: "90%",
}}
transition={{ delay: 0.5, duration: 0.8 }}
className={`h-full rounded-full ${
analysisData.risk_level === "low"
? "bg-morandi-sage-400"
: analysisData.risk_level === "medium"
? "bg-morandi-rose-400"
: "bg-red-500"
}`}
/>
</div>
</div>
</Card>
</motion.div>
{/* 分类详情 */}
<motion.div
initial={{ opacity: 0, y: 20 }}
animate={{ opacity: 1, y: 0 }}
transition={{ delay: 0.2 }}
>
<h2 className="text-lg font-semibold text-gray-800 mb-4">详细解读</h2>
<div className="space-y-4">
{analysisData.categories.map((category, index) => {
const colors = getStatusColor(category.status);
return (
<motion.div
key={category.category}
initial={{ opacity: 0, y: 20 }}
animate={{ opacity: 1, y: 0 }}
transition={{ delay: 0.3 + index * 0.1 }}
>
<Card className="bg-white rounded-3xl shadow-sm overflow-hidden border-0">
{/* 分类头部 */}
<div className={`${colors.bg} border-b-2 ${colors.border} p-5`}>
<div className="flex items-center gap-3 mb-2">
<div
className={`w-10 h-10 ${colors.badge} rounded-xl flex items-center justify-center`}
>
<Activity className={`w-5 h-5 ${colors.icon}`} />
</div>
<div className="flex-1">
<h3 className="font-semibold text-gray-800">
{category.category}
</h3>
</div>
<Badge className={colors.badge}>
{category.status === "normal"
? "正常"
: category.status === "attention"
? "需关注"
: "异常"}
</Badge>
</div>
</div>
{/* 指标详情 */}
<div className="p-5 space-y-4">
{category.indicators.map((item, itemIdx) => {
const indicatorStatus = item.status === "normal" ? "normal" : item.status === "low" || item.status === "high" ? "abnormal" : "borderline";
const indColors = getIndicatorColor(indicatorStatus);
return (
<div
key={itemIdx}
className={`${indColors.bg} border-2 ${indColors.border} rounded-2xl p-4`}
>
{/* 指标名称和数值 */}
<div className="flex items-start justify-between mb-3">
<div className="flex-1">
<h4 className="font-semibold text-gray-800 mb-1">
{item.name}
</h4>
{item.nickname && (
<p className="text-xs text-gray-500 mb-2">
{item.nickname}
</p>
)}
<div className="flex items-baseline gap-2">
<span
className={`text-2xl font-semibold ${indColors.text}`}
>
{item.value}
</span>
<span className="text-sm text-gray-500">
{item.unit}
</span>
</div>
{item.reference_range && (
<p className="text-xs text-gray-400 mt-1">
参考范围:{item.reference_range}
</p>
)}
</div>
<div className="flex flex-col items-end gap-1.5">
<div
className={`px-3 py-1 ${indColors.badge} rounded-full flex items-center gap-1`}
>
{indicatorStatus === "normal" ? (
<CheckCircle2 className="w-4 h-4" />
) : (
<AlertCircle className="w-4 h-4" />
)}
<span className="text-xs font-medium">
{item.status === "normal" ? "正常" : item.status === "high" ? "偏高" : "偏低"}
</span>
</div>
</div>
</div>
{/* 状态指示条 */}
<div className="mb-3">
<div className={`h-1.5 ${indColors.barBg} rounded-full overflow-hidden`}>
<div
className={`h-full ${indColors.barFill} rounded-full transition-all`}
style={{
width: indicatorStatus === "normal" ? "100%" : indicatorStatus === "borderline" ? "60%" : "30%",
}}
/>
</div>
</div>
{/* 白话解释 */}
<div className="space-y-3">
{item.what_is_it && (
<div className="bg-white/70 rounded-xl p-3">
<div className="flex items-start gap-2">
<Info className="w-4 h-4 text-morandi-400 flex-shrink-0 mt-0.5" />
<div className="flex-1">
<p className="text-xs font-medium text-gray-700 mb-1">这是什么?</p>
<p className="text-xs text-gray-600 leading-relaxed">{item.what_is_it}</p>
</div>
</div>
</div>
)}
{item.baby_impact && (
<div className="bg-white/70 rounded-xl p-3">
<div className="flex items-start gap-2">
<Baby className="w-4 h-4 text-morandi-400 flex-shrink-0 mt-0.5" />
<div className="flex-1">
<p className="text-xs font-medium text-gray-700 mb-1">对宝宝的影响</p>
<p className="text-xs text-gray-600 leading-relaxed">{item.baby_impact}</p>
</div>
</div>
</div>
)}
{item.suggestion && (
<div className="bg-morandi-100/50 rounded-xl p-3">
<div className="flex items-start gap-2">
<Heart className="w-4 h-4 text-morandi-400 flex-shrink-0 mt-0.5" />
<div className="flex-1">
<p className="text-xs font-medium text-gray-700 mb-1">建议措施</p>
<p className="text-xs text-gray-600 leading-relaxed">{item.suggestion}</p>
</div>
</div>
</div>
)}
{item.follow_up && (
<div className="flex items-center gap-2 text-xs text-morandi-500">
<Calendar className="w-3 h-3" />
<span>{item.follow_up}</span>
</div>
)}
</div>
</div>
);
})}
</div>
</Card>
</motion.div>
);
})}
</div>
</motion.div>
{/* 行动建议 */}
<motion.div
initial={{ opacity: 0, y: 20 }}
animate={{ opacity: 1, y: 0 }}
transition={{ delay: 0.5 }}
>
<h2 className="text-lg font-semibold text-gray-800 mb-4">
接下来该做什么?
</h2>
<div className="space-y-3">
{analysisData.action_items.map((rec, index) => (
<motion.div
key={index}
initial={{ opacity: 0, x: -20 }}
animate={{ opacity: 1, x: 0 }}
transition={{ delay: 0.6 + index * 0.1 }}
>
<Card
className={`rounded-2xl p-4 border-2 ${
rec.priority === "high"
? "bg-morandi-rose-100 border-morandi-200"
: rec.priority === "medium"
? "bg-morandi-50 border-morandi-200"
: "bg-gray-50 border-gray-200"
}`}
>
<div className="flex items-start gap-3">
<div className="flex-1">
<div className="flex items-center gap-2 mb-1">
<h4 className="font-semibold text-gray-800">
{rec.title}
</h4>
{rec.priority === "high" && (
<Badge className="bg-morandi-rose-100 text-morandi-600 text-xs">
重要
</Badge>
)}
</div>
<p className="text-sm text-gray-600 mb-2">
{rec.description}
</p>
{rec.timeframe && (
<div className="flex items-center gap-2 text-xs text-gray-500">
<Calendar className="w-3 h-3" />
<span>{rec.timeframe}</span>
</div>
)}
</div>
</div>
</Card>
</motion.div>
))}
</div>
</motion.div>
{/* 深度服务 - 新增 */}
<motion.div
initial={{ opacity: 0, y: 20 }}
animate={{ opacity: 1, y: 0 }}
transition={{ delay: 0.7 }}
>
<h2 className="text-lg font-semibold text-gray-800 mb-4">
🎯 获取更多帮助
</h2>
<div className="grid grid-cols-2 gap-3">
<Card
onClick={() => navigate(`/chat/${reportId}`)}
className="bg-morandi-100 rounded-2xl p-4 border-0 cursor-pointer hover:scale-105 transition-transform"
>
<div className="w-12 h-12 bg-morandi-400 rounded-xl flex items-center justify-center mb-3">
<MessageCircle className="w-6 h-6 text-white" />
</div>
<h3 className="font-semibold text-gray-800 mb-1">AI对话追问</h3>
<p className="text-xs text-gray-600">针对报告提问</p>
</Card>
<Card
onClick={() => navigate(`/doctor-questions/${reportId}`)}
className="bg-gradient-to-br from-morandi-slate-100 to-morandi-slate-50 rounded-2xl p-4 border-0 cursor-pointer hover:scale-105 transition-transform"
>
<div className="w-12 h-12 bg-morandi-slate-500 rounded-xl flex items-center justify-center mb-3">
<FileText className="w-6 h-6 text-white" />
</div>
<h3 className="font-semibold text-gray-800 mb-1">问诊清单</h3>
<p className="text-xs text-gray-600">就医沟通话术</p>
</Card>
</div>
</motion.div>
{/* 温馨提示 */}
<motion.div
initial={{ opacity: 0, y: 20 }}
animate={{ opacity: 1, y: 0 }}
transition={{ delay: 0.9 }}
>
<Card className="bg-morandi-50 border-morandi-200 rounded-2xl p-4">
<div className="flex items-start gap-3">
<Heart className="w-5 h-5 text-morandi-400 flex-shrink-0 mt-0.5" />
<div className="flex-1">
<h4 className="font-semibold text-gray-800 mb-1">
温馨提示
</h4>
<p className="text-sm text-gray-600 leading-relaxed">
以上解读仅供参考,不能替代医生的专业诊断。如有任何疑问或不适,请及时就医咨询专业医生。保持好心情,宝宝能感受到妈妈的情绪哦 💕
</p>
</div>
</div>
</Card>
</motion.div>
</div>
{/* 底部操作栏 */}
<div className="fixed bottom-0 left-0 right-0 bg-white border-t border-gray-200 p-4 pb-safe z-10">
<div className="max-w-md mx-auto flex gap-3">
<Button
onClick={() => navigate("/home")}
variant="outline"
className="flex-1 h-12 rounded-xl border-2 border-gray-300"
>
返回首页
</Button>
<Button
onClick={() => navigate(`/report/${reportId}`)}
className="flex-1 h-12 rounded-xl bg-gradient-to-r from-morandi-400 to-morandi-500 hover:from-morandi-500 hover:to-morandi-500 text-white"
>
查看原始报告
</Button>
</div>
</div>
{/* 分享弹窗 */}
<AnimatePresence>
{showShareSheet && (
<>
<motion.div
initial={{ opacity: 0 }}
animate={{ opacity: 1 }}
exit={{ opacity: 0 }}
onClick={() => setShowShareSheet(false)}
className="fixed inset-0 bg-black/40 z-40"
/>
<motion.div
initial={{ y: "100%" }}
animate={{ y: 0 }}
exit={{ y: "100%" }}
transition={{ type: "spring", damping: 25, stiffness: 300 }}
className="fixed bottom-0 left-0 right-0 bg-white rounded-t-3xl z-50 max-h-[70vh] overflow-y-auto"
>
<div className="mx-auto p-5">
{/* 拖拽指示器 */}
<div className="w-10 h-1 bg-gray-200 rounded-full mx-auto mb-4" />
<div className="flex items-center justify-between mb-5">
<h3 className="font-semibold text-gray-800 text-lg">分享报告</h3>
<button
onClick={() => setShowShareSheet(false)}
className="w-8 h-8 rounded-full hover:bg-gray-100 flex items-center justify-center"
>
<X className="w-5 h-5 text-gray-400" />
</button>
</div>
{/* 分享方式 */}
<div className="space-y-3 mb-6">
{/* 生成分享长图 */}
<button
onClick={() => {
setShowShareSheet(false);
navigate(`/share/${reportId}`);
}}
className="w-full flex items-center gap-4 py-4 px-4 bg-gradient-to-r from-morandi-100 to-morandi-50 hover:from-morandi-200 hover:to-morandi-100 rounded-2xl transition-colors"
>
<div className="w-12 h-12 bg-gradient-to-br from-morandi-400 to-morandi-500 rounded-xl flex items-center justify-center flex-shrink-0">
<ImageIcon className="w-6 h-6 text-white" />
</div>
<div className="text-left flex-1">
<div className="font-semibold text-gray-800">
生成分享长图
</div>
<div className="text-xs text-gray-500">
生成精美报告长图,一键保存到相册
</div>
</div>
<ChevronLeft className="w-5 h-5 text-gray-300 rotate-180" />
</button>
{/* 复制链接 */}
<button
onClick={handleCopyLink}
className="w-full flex items-center gap-4 py-4 px-4 bg-morandi-50 hover:bg-morandi-100 rounded-2xl transition-colors"
>
<div className="w-12 h-12 bg-morandi-400 rounded-xl flex items-center justify-center flex-shrink-0">
{linkCopied ? (
<Check className="w-6 h-6 text-white" />
) : (
<Link2 className="w-6 h-6 text-white" />
)}
</div>
<div className="text-left flex-1">
<div className="font-semibold text-gray-800">
{linkCopied ? "链接已复制" : "复制分享链接"}
</div>
<div className="text-xs text-gray-500">
{linkCopied ? "可以分享给家人或医生" : "复制链接发送给家人或医生"}
</div>
</div>
</button>
</div>
{/* 分享预览卡片 */}
<Card className="bg-morandi-50 border-morandi-100 rounded-2xl p-4 mb-4">
<div className="flex items-center gap-3">
<div className="w-12 h-12 bg-gradient-to-br from-morandi-400 to-morandi-500 rounded-xl flex items-center justify-center flex-shrink-0">
<FileText className="w-6 h-6 text-white" />
</div>
<div className="flex-1 min-w-0">
<h4 className="font-medium text-gray-800 text-sm truncate">
{analysisData.categories[0]?.category || "检查报告"} · AI解读
</h4>
<p className="text-xs text-gray-500 mt-0.5 truncate">
{analysisData.overall_summary}
</p>
<div className="flex items-center gap-2 mt-1">
<span className="text-xs text-gray-400">{new Date(analysisData.created_at).toLocaleDateString("zh-CN")}</span>
</div>
</div>
</div>
</Card>
{/* 隐私提示 */}
<div className="flex items-center gap-2 text-xs text-gray-400 mb-2">
<Info className="w-3.5 h-3.5 flex-shrink-0" />
<span>分享时将自动隐藏个人敏感信息,保护您的隐私</span>
</div>
</div>
</motion.div>
</>
)}
</AnimatePresence>
</div>
);
}