feat: 初始化孕育无忧全栈项目基础代码结构

完成项目初始搭建,包含后端FastAPI+MongoDB服务、前端React+Vite项目的完整基础结构,添加各类配置文件与依赖清单,涵盖核心业务初始框架。
This commit is contained in:
2026-05-21 00:46:48 +08:00
commit 409dabafb5
142 changed files with 31010 additions and 0 deletions

View File

@@ -0,0 +1,721 @@
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>
);
}