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,574 @@
import { useState, useEffect, useRef, useCallback } from "react";
import { useNavigate, useParams } from "react-router";
import { motion, AnimatePresence } from "motion/react";
import {
ChevronLeft,
Share2,
CheckCircle2,
AlertCircle,
Info,
Loader2,
Heart,
Baby,
TrendingUp,
Download,
Check,
Image as ImageIcon,
} from "lucide-react";
import { toPng } from "html-to-image";
import { Card } from "../components/ui/card";
import { Badge } from "../components/ui/badge";
import { Button } from "../components/ui/button";
import { generateShareData } from "../services/api";
type SaveStatus = "idle" | "generating" | "success" | "error";
export function SharePage() {
const navigate = useNavigate();
const { reportId } = useParams();
const [loading, setLoading] = useState(true);
const [shareData, setShareData] = useState<any>(null);
const [saveStatus, setSaveStatus] = useState<SaveStatus>("idle");
const [imageUrl, setImageUrl] = useState<string | null>(null);
const cardRef = useRef<HTMLDivElement>(null);
useEffect(() => {
if (!reportId) return;
generateShareData(reportId)
.then((data) => {
setShareData(data.share_data);
})
.catch(() => {/* keep null */})
.finally(() => setLoading(false));
}, [reportId]);
// 生成长图
const generateImage = useCallback(async (): Promise<string | null> => {
if (!cardRef.current) return null;
try {
// html-to-image: 将 DOM 节点转为 PNG data URL
const dataUrl = await toPng(cardRef.current, {
cacheBust: true,
pixelRatio: 2, // 2x 高清
backgroundColor: "#ffffff",
style: {
// 确保截图时移除圆角阴影的裁切问题
borderRadius: "0",
},
});
return dataUrl;
} catch (err) {
console.error("生成图片失败:", err);
return null;
}
}, []);
// 一键保存到相册 / 下载
const handleSaveImage = useCallback(async () => {
setSaveStatus("generating");
try {
const dataUrl = imageUrl || (await generateImage());
if (!dataUrl) {
setSaveStatus("error");
setTimeout(() => setSaveStatus("idle"), 2000);
return;
}
if (!imageUrl) setImageUrl(dataUrl);
// 尝试使用 Web Share API(移动端可直接分享/保存到相册)
if (navigator.share && navigator.canShare) {
try {
const response = await fetch(dataUrl);
const blob = await response.blob();
const file = new File([blob], `孕育无忧-报告解读-${reportId}.png`, {
type: "image/png",
});
if (navigator.canShare({ files: [file] })) {
await navigator.share({
files: [file],
title: "孕育无忧 · AI 报告解读",
});
setSaveStatus("success");
setTimeout(() => setSaveStatus("idle"), 3000);
return;
}
} catch (shareErr) {
// 用户取消分享或不支持,降级到下载
if ((shareErr as Error).name === "AbortError") {
setSaveStatus("idle");
return;
}
}
}
// 降级方案:触发下载(iOS Safari / Android 浏览器)
const link = document.createElement("a");
link.download = `孕育无忧-报告解读-${reportId}.png`;
link.href = dataUrl;
link.style.display = "none";
document.body.appendChild(link);
link.click();
document.body.removeChild(link);
setSaveStatus("success");
setTimeout(() => setSaveStatus("idle"), 3000);
} catch (err) {
console.error("保存失败:", err);
setSaveStatus("error");
setTimeout(() => setSaveStatus("idle"), 2000);
}
}, [imageUrl, generateImage, reportId]);
// 预览:在页面加载数据后自动生成图片预览
useEffect(() => {
if (shareData && cardRef.current && !imageUrl) {
// 延迟一帧确保 DOM 渲染完毕
const timer = setTimeout(async () => {
const url = await generateImage();
if (url) setImageUrl(url);
}, 500);
return () => clearTimeout(timer);
}
}, [shareData, generateImage, imageUrl]);
if (loading || !shareData) {
return (
<div className="min-h-dvh bg-morandi-50 flex items-center justify-center">
<div className="text-center">
<Loader2 className="w-8 h-8 text-morandi-400 animate-spin mx-auto mb-3" />
<p className="text-sm text-gray-500">正在准备分享内容...</p>
</div>
</div>
);
}
const getStatusColor = (status: string) => {
switch (status) {
case "normal":
return {
bg: "bg-morandi-sage-100",
text: "text-morandi-sage-600",
icon: "text-morandi-sage-400",
};
case "attention":
return {
bg: "bg-morandi-rose-100",
text: "text-morandi-600",
icon: "text-morandi-rose-400",
};
default:
return {
bg: "bg-gray-100",
text: "text-gray-600",
icon: "text-gray-400",
};
}
};
const statusColors = getStatusColor(shareData.overall_status);
const getSaveButtonContent = () => {
switch (saveStatus) {
case "generating":
return (
<>
<Loader2 className="w-5 h-5 animate-spin" />
<span>正在生成图片...</span>
</>
);
case "success":
return (
<>
<Check className="w-5 h-5" />
<span>已保存到相册</span>
</>
);
case "error":
return (
<>
<AlertCircle className="w-5 h-5" />
<span>保存失败,请重试</span>
</>
);
default:
return (
<>
<Download className="w-5 h-5" />
<span>保存图片到相册</span>
</>
);
}
};
return (
<div className="min-h-dvh bg-morandi-50 pb-28">
{/* 顶部导航 */}
<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">
<Share2 className="w-5 h-5 text-white" />
</div>
<div>
<h1 className="font-semibold text-gray-800">分享长图</h1>
<p className="text-xs text-gray-500">生成精美报告长图</p>
</div>
</div>
</div>
</div>
</div>
<div className="mx-auto p-4 space-y-5">
{/* 图片预览区域 */}
<motion.div
initial={{ opacity: 0, y: 20 }}
animate={{ opacity: 1, y: 0 }}
>
{/* 如果已经生成了图片,直接展示图片(可长按保存) */}
{imageUrl ? (
<div className="relative">
<img
src={imageUrl}
alt="分享长图"
className="w-full rounded-3xl shadow-lg"
style={{ userSelect: "none" }}
/>
{/* 长按提示覆盖层 */}
<div className="absolute bottom-0 left-0 right-0 bg-gradient-to-t from-black/30 to-transparent rounded-b-3xl px-4 py-3">
<p className="text-white text-xs text-center opacity-80">
💡 长按图片也可直接保存
</p>
</div>
</div>
) : (
<div className="flex items-center justify-center py-16 bg-white rounded-3xl shadow-lg">
<div className="text-center">
<Loader2 className="w-8 h-8 text-morandi-400 animate-spin mx-auto mb-3" />
<p className="text-sm text-gray-500">正在生成长图预览...</p>
</div>
</div>
)}
</motion.div>
{/* 隐藏的 HTML 卡片(用于截图,不展示给用户) */}
<div
style={{
position: "absolute",
left: "-9999px",
top: 0,
width: "375px", // 固定 iPhone 宽度,保证截图一致
}}
>
<div ref={cardRef} style={{ background: "#ffffff", padding: "0" }}>
<ShareCardContent shareData={shareData} getStatusColor={getStatusColor} statusColors={statusColors} />
</div>
</div>
{/* 操作提示 */}
<motion.div
initial={{ opacity: 0, y: 20 }}
animate={{ opacity: 1, y: 0 }}
transition={{ delay: 0.1 }}
>
<Card className="bg-morandi-50 border-morandi-200 rounded-2xl p-4">
<div className="flex items-start gap-3">
<Info className="w-5 h-5 text-morandi-400 flex-shrink-0 mt-0.5" />
<div>
<h4 className="font-semibold text-gray-800 text-sm mb-2">💡 分享说明</h4>
<div className="space-y-1.5">
<p className="text-xs text-gray-600 leading-relaxed">
• 点击下方按钮,一键保存图片到手机相册
</p>
<p className="text-xs text-gray-600 leading-relaxed">
• 也可以长按上方图片直接保存
</p>
<p className="text-xs text-gray-600 leading-relaxed">
• 个人信息已自动脱敏处理,保护您的隐私
</p>
</div>
</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">
<Button
onClick={handleSaveImage}
disabled={saveStatus === "generating"}
className={`w-full h-14 rounded-2xl text-base font-semibold flex items-center justify-center gap-2 transition-all ${
saveStatus === "success"
? "bg-morandi-sage-400 hover:bg-morandi-sage-500 text-white"
: saveStatus === "error"
? "bg-red-400 hover:bg-red-500 text-white"
: "bg-gradient-to-r from-morandi-400 to-morandi-500 hover:from-morandi-500 hover:to-morandi-600 text-white shadow-lg shadow-morandi-300/30"
}`}
>
{getSaveButtonContent()}
</Button>
<p className="text-center text-xs text-gray-400 mt-2">
图片将保存到您的手机相册中
</p>
</div>
</div>
</div>
);
}
/**
* 分享卡片内容组件(纯 HTML/CSS,不使用 motion 动画,用于截图)
* 独立提取,确保截图时样式完整、无动画干扰
*/
function ShareCardContent({
shareData,
getStatusColor,
statusColors,
}: {
shareData: any;
getStatusColor: (status: string) => { bg: string; text: string; icon: string };
statusColors: { bg: string; text: string; icon: string };
}) {
return (
<div style={{ background: "#ffffff", fontFamily: "-apple-system, BlinkMacSystemFont, 'Segoe UI', sans-serif" }}>
<div style={{ padding: "24px", display: "flex", flexDirection: "column", gap: "20px" }}>
{/* 标题区 */}
<div style={{ textAlign: "center", paddingBottom: "20px", borderBottom: "2px solid #f3f4f6" }}>
<div
style={{
display: "inline-flex",
alignItems: "center",
gap: "8px",
padding: "8px 16px",
background: "linear-gradient(135deg, #b8a9c9, #9d8ab5)",
color: "white",
borderRadius: "9999px",
fontSize: "14px",
fontWeight: 600,
marginBottom: "16px",
}}
>
<Heart style={{ width: 16, height: 16, fill: "white", color: "white" }} />
<span>孕育无忧 · AI 智能解读</span>
</div>
<h2
style={{
fontSize: "24px",
fontWeight: 700,
color: "#1f2937",
marginBottom: "8px",
marginTop: "0",
}}
>
{shareData.report_type}
</h2>
<div style={{ display: "flex", alignItems: "center", justifyContent: "center", gap: "12px", fontSize: "14px", color: "#6b7280" }}>
{shareData.pregnancy_week && <span>孕 {shareData.pregnancy_week} 周</span>}
{shareData.user_name && (
<>
<span>·</span>
<span>{shareData.user_name}</span>
</>
)}
</div>
</div>
{/* 整体评估 */}
<div className={`${statusColors.bg} rounded-2xl p-5`}>
<div style={{ display: "flex", alignItems: "center", gap: "12px", marginBottom: "12px" }}>
<div
style={{
width: 48,
height: 48,
background: "rgba(255,255,255,0.5)",
borderRadius: "12px",
display: "flex",
alignItems: "center",
justifyContent: "center",
}}
>
<Baby className={`w-6 h-6 ${statusColors.icon}`} />
</div>
<div style={{ flex: 1 }}>
<h3 style={{ fontWeight: 700, color: "#1f2937", fontSize: "18px", margin: 0 }}>
整体评估
</h3>
<Badge className={`${statusColors.bg} ${statusColors.text} mt-1`}>
{shareData.overall_status === "normal" ? "一切正常" : "需要关注"}
</Badge>
</div>
</div>
<p className={`text-sm ${statusColors.text} leading-relaxed`} style={{ margin: 0 }}>
{shareData.emotional_summary || shareData.overall_summary}
</p>
</div>
{/* 详细指标 */}
{shareData.categories && shareData.categories.length > 0 && (
<div style={{ display: "flex", flexDirection: "column", gap: "16px" }}>
<h3 style={{ fontWeight: 700, color: "#1f2937", fontSize: "18px", display: "flex", alignItems: "center", gap: "8px", margin: 0 }}>
<TrendingUp className="w-5 h-5 text-morandi-400" />
详细指标
</h3>
{shareData.categories.map((category: any, idx: number) => {
const catColors = getStatusColor(category.status);
return (
<div key={idx} className={`${catColors.bg} rounded-2xl p-4`}>
<div style={{ display: "flex", alignItems: "center", gap: "8px", marginBottom: "12px" }}>
<h4 style={{ fontWeight: 600, color: "#1f2937", margin: 0 }}>{category.category}</h4>
<Badge className={`${catColors.bg} ${catColors.text} text-xs`}>
{category.status === "normal" ? "正常" : "需关注"}
</Badge>
</div>
<div style={{ display: "flex", flexDirection: "column", gap: "12px" }}>
{category.indicators.map((ind: any, indIdx: number) => (
<div key={indIdx} style={{ background: "rgba(255,255,255,0.7)", borderRadius: "12px", padding: "12px" }}>
<div style={{ display: "flex", alignItems: "flex-start", justifyContent: "space-between", marginBottom: "8px" }}>
<div style={{ flex: 1 }}>
<div style={{ fontWeight: 600, color: "#1f2937", fontSize: "14px", marginBottom: "4px" }}>
{ind.name}
{ind.nickname && (
<span style={{ fontSize: "12px", color: "#9ca3af", marginLeft: "8px" }}>({ind.nickname})</span>
)}
</div>
{ind.value && (
<div style={{ fontSize: "18px", fontWeight: 700, color: "#7c6995" }}>
{ind.value} {ind.unit}
</div>
)}
</div>
<Badge className={`text-xs ${
ind.status === "normal"
? "bg-morandi-sage-100 text-morandi-sage-600"
: "bg-morandi-rose-100 text-morandi-rose-600"
}`}>
{ind.status === "normal" ? "正常" : ind.status === "high" ? "偏高" : "偏低"}
</Badge>
</div>
{ind.what_is_it && (
<p style={{ fontSize: "12px", color: "#6b7280", marginBottom: "8px", margin: "0 0 8px 0" }}>
{ind.what_is_it}
</p>
)}
{ind.suggestion && (
<div style={{ background: "#faf7fc", borderRadius: "8px", padding: "8px", marginTop: "8px" }}>
<p style={{ fontSize: "12px", color: "#4b5563", margin: 0 }}>💡 {ind.suggestion}</p>
</div>
)}
</div>
))}
</div>
</div>
);
})}
</div>
)}
{/* 行动建议 */}
{shareData.action_items && shareData.action_items.length > 0 && (
<div>
<h3 style={{ fontWeight: 700, color: "#1f2937", fontSize: "18px", marginBottom: "12px", marginTop: 0 }}>
接下来该做什么?
</h3>
<div style={{ display: "flex", flexDirection: "column", gap: "8px" }}>
{shareData.action_items.map((item: any, idx: number) => (
<div
key={idx}
style={{
borderRadius: "12px",
padding: "12px",
background: item.priority === "high" ? "#fce4ec" : "#faf7fc",
}}
>
<div style={{ display: "flex", alignItems: "flex-start", gap: "8px" }}>
<span
style={{
flexShrink: 0,
width: 20,
height: 20,
background: "#b8a9c9",
color: "white",
borderRadius: "50%",
display: "flex",
alignItems: "center",
justifyContent: "center",
fontSize: "12px",
fontWeight: 700,
marginTop: "2px",
}}
>
{idx + 1}
</span>
<div style={{ flex: 1 }}>
<div style={{ fontWeight: 600, color: "#1f2937", fontSize: "14px", marginBottom: "4px" }}>
{item.title || item.content}
</div>
{item.description && (
<p style={{ fontSize: "12px", color: "#6b7280", margin: 0 }}>{item.description}</p>
)}
</div>
</div>
</div>
))}
</div>
</div>
)}
{/* 温馨提示 */}
{shareData.tips && shareData.tips.length > 0 && (
<div className="bg-morandi-50 rounded-2xl p-4">
<h4 style={{ fontWeight: 600, color: "#1f2937", fontSize: "14px", marginBottom: "8px", display: "flex", alignItems: "center", gap: "8px", marginTop: 0 }}>
<Heart className="w-4 h-4 text-morandi-400" />
温馨提示
</h4>
<ul style={{ listStyle: "none", padding: 0, margin: 0, display: "flex", flexDirection: "column", gap: "4px" }}>
{shareData.tips.map((tip: string, idx: number) => (
<li key={idx} style={{ fontSize: "12px", color: "#6b7280", display: "flex", alignItems: "flex-start", gap: "8px" }}>
<span style={{ color: "#b8a9c9" }}>•</span>
<span>{tip}</span>
</li>
))}
</ul>
</div>
)}
{/* 底部声明 */}
<div style={{ paddingTop: "20px", borderTop: "2px solid #f3f4f6", textAlign: "center" }}>
<p style={{ fontSize: "12px", color: "#9ca3af", marginBottom: "4px", margin: "0 0 4px 0" }}>
本报告由 AI 生成,仅供参考,不能替代医生诊断
</p>
<p style={{ fontSize: "12px", color: "#9ca3af", marginBottom: "8px", margin: "0 0 8px 0" }}>
如有疑问请及时咨询专业医生
</p>
<div
style={{
display: "inline-flex",
alignItems: "center",
gap: "4px",
fontSize: "11px",
color: "#b8a9c9",
padding: "4px 12px",
background: "#faf7fc",
borderRadius: "9999px",
}}
>
<Heart style={{ width: 12, height: 12, fill: "#b8a9c9", color: "#b8a9c9" }} />
<span>孕育无忧 · yun.tun88.com</span>
</div>
</div>
</div>
</div>
);
}