chore: 初始化项目结构并添加基础配置文件

添加后端API和前端项目的初始目录结构,包括应用层模块、工具模块和模型定义
配置前后端的.dockerignore文件以排除开发环境文件
添加前端样式文件,包括字体、Tailwind CSS和主题配置
初始化Python虚拟环境中的类型标记文件
This commit is contained in:
2026-04-01 16:55:24 +08:00
commit cfdeeb5e41
4149 changed files with 837587 additions and 0 deletions

View File

@@ -0,0 +1,624 @@
import { useState, useEffect } from "react";
import { useNavigate } from "react-router";
import { motion } from "motion/react";
import {
ChevronLeft,
TrendingUp,
Info,
Download,
Share2,
Loader2,
Heart,
} from "lucide-react";
import { Button } from "../components/ui/button";
import { Card } from "../components/ui/card";
import { Badge } from "../components/ui/badge";
import {
LineChart,
Line,
XAxis,
YAxis,
CartesianGrid,
Tooltip,
Legend,
ResponsiveContainer,
Area,
AreaChart,
} from "recharts";
import { getGrowthCurve } from "../services/api";
import { useUser } from "../context/UserContext";
// 宝宝预期大小数据(按孕周,基于WHO标准)
// 4-19周:身长为头臀长(CRL);20周起:身长为头到脚全身长
const BABY_EXPECTED_DATA: Record<number, { emoji: string; fruit: string; length: string; weight: string; highlight: string }> = {
4: { emoji: "🫘", fruit: "罂粟种子", length: "0.1cm", weight: "<1g", highlight: "心脏开始跳动" },
5: { emoji: "🫘", fruit: "芝麻粒", length: "0.2cm", weight: "<1g", highlight: "神经管开始发育" },
6: { emoji: "🫘", fruit: "小扁豆", length: "0.6cm", weight: "<1g", highlight: "小手小脚在萌芽" },
7: { emoji: "🫐", fruit: "蓝莓", length: "1.3cm", weight: "<1g", highlight: "五官开始形成" },
8: { emoji: "🫐", fruit: "覆盆子", length: "1.6cm", weight: "1g", highlight: "手指脚趾开始分化" },
9: { emoji: "🍇", fruit: "葡萄", length: "2.3cm", weight: "2g", highlight: "可以微微活动了" },
10: { emoji: "🍊", fruit: "金橘", length: "3.1cm", weight: "4g", highlight: "所有器官已初步形成" },
11: { emoji: "🫒", fruit: "无花果", length: "4.1cm", weight: "7g", highlight: "能做出吞咽动作" },
12: { emoji: "🍋", fruit: "青柠", length: "5.4cm", weight: "14g", highlight: "进入稳定的孕中期" },
13: { emoji: "🍋", fruit: "柠檬", length: "7.4cm", weight: "23g", highlight: "指纹开始形成" },
14: { emoji: "🍑", fruit: "桃子", length: "8.7cm", weight: "43g", highlight: "能做出表情了" },
16: { emoji: "🥑", fruit: "牛油果", length: "11.6cm", weight: "100g", highlight: "开始能听到声音" },
18: { emoji: "🫑", fruit: "甜椒", length: "14.2cm", weight: "190g", highlight: "妈妈可能感到胎动了" },
20: { emoji: "🍌", fruit: "香蕉", length: "25.6cm", weight: "300g", highlight: "味觉开始发育" },
22: { emoji: "🥭", fruit: "木瓜", length: "27.8cm", weight: "430g", highlight: "眉毛和眼睫毛长出" },
24: { emoji: "🌽", fruit: "玉米", length: "30cm", weight: "600g", highlight: "肺部开始发育" },
26: { emoji: "🥦", fruit: "菜花", length: "35.6cm", weight: "760g", highlight: "能睁开眼睛了" },
28: { emoji: "🍆", fruit: "茄子", length: "37.6cm", weight: "1000g", highlight: "开始有规律的作息" },
30: { emoji: "🥬", fruit: "卷心菜", length: "39.9cm", weight: "1320g", highlight: "大脑快速发育中" },
32: { emoji: "🥥", fruit: "椰子", length: "42.4cm", weight: "1700g", highlight: "皮下脂肪增多" },
34: { emoji: "🍈", fruit: "哈密瓜", length: "45cm", weight: "2150g", highlight: "肺部接近成熟" },
36: { emoji: "🍈", fruit: "蜜瓜", length: "47.4cm", weight: "2620g", highlight: "头部开始入盆" },
38: { emoji: "🎃", fruit: "南瓜", length: "49.8cm", weight: "3080g", highlight: "各器官发育成熟" },
40: { emoji: "🍉", fruit: "小西瓜", length: "51.2cm", weight: "3460g", highlight: "随时准备来到这个世界" },
};
function getBabyExpectedData(week: number) {
const weeks = Object.keys(BABY_EXPECTED_DATA).map(Number).sort((a, b) => a - b);
let closest = weeks[0];
for (const w of weeks) {
if (w <= week) closest = w;
else break;
}
return BABY_EXPECTED_DATA[closest];
}
// 生成孕周里程碑时间线数据
function getGrowthMilestones(currentWeek: number) {
const milestones = [
{ week: 8, label: "心跳可检测", emoji: "💓" },
{ week: 12, label: "首次B超", emoji: "🔍" },
{ week: 16, label: "开始感知声音", emoji: "👂" },
{ week: 20, label: "大排畸检查", emoji: "🏥" },
{ week: 24, label: "肺部发育", emoji: "🫁" },
{ week: 28, label: "眼睛能睁开", emoji: "👀" },
{ week: 32, label: "快速增重", emoji: "📈" },
{ week: 36, label: "接近足月", emoji: "🎯" },
{ week: 40, label: "预产期到来", emoji: "🎉" },
];
return milestones.map(m => ({ ...m, passed: currentWeek >= m.week }));
}
export function GrowthCurvePage() {
const navigate = useNavigate();
const { user } = useUser();
const currentWeek = user.currentWeek || 0;
const [selectedMetric, setSelectedMetric] = useState("bpd");
const [loading, setLoading] = useState(true);
// Standard reference data (no fake values)
const standardData: Record<string, { name: string; unit: string; description: string; data: { week: number; value?: number; standard: number; lower: number; upper: number }[] }> = {
bpd: {
name: "双顶径 (BPD)",
unit: "mm",
description: "胎儿头部左右两侧之间最宽部位的长度",
data: [
{ week: 12, standard: 20, lower: 18, upper: 22 },
{ week: 16, standard: 35, lower: 32, upper: 38 },
{ week: 20, standard: 47, lower: 43, upper: 51 },
{ week: 24, standard: 60, lower: 56, upper: 64 },
{ week: 28, standard: 71, lower: 67, upper: 75 },
{ week: 32, standard: 81, lower: 77, upper: 85 },
{ week: 36, standard: 89, lower: 85, upper: 93 },
],
},
ac: {
name: "腹围 (AC)",
unit: "mm",
description: "胎儿腹部一周的长度",
data: [
{ week: 12, standard: 60, lower: 54, upper: 66 },
{ week: 16, standard: 102, lower: 94, upper: 110 },
{ week: 20, standard: 148, lower: 138, upper: 158 },
{ week: 24, standard: 192, lower: 180, upper: 204 },
{ week: 28, standard: 235, lower: 221, upper: 249 },
{ week: 32, standard: 279, lower: 263, upper: 295 },
{ week: 36, standard: 318, lower: 300, upper: 336 },
],
},
fl: {
name: "股骨长 (FL)",
unit: "mm",
description: "胎儿大腿骨的长度",
data: [
{ week: 12, standard: 8, lower: 7, upper: 9 },
{ week: 16, standard: 22, lower: 20, upper: 24 },
{ week: 20, standard: 34, lower: 31, upper: 37 },
{ week: 24, standard: 45, lower: 42, upper: 48 },
{ week: 28, standard: 54, lower: 51, upper: 57 },
{ week: 32, standard: 63, lower: 60, upper: 66 },
{ week: 36, standard: 70, lower: 67, upper: 73 },
],
},
};
const [growthData, setGrowthData] = useState(standardData);
useEffect(() => {
getGrowthCurve()
.then((data) => {
if (data.data_points && data.data_points.length > 0) {
// Merge API data points into standard data
const updated = { ...standardData };
for (const metric of ["bpd", "ac", "fl"] as const) {
const apiPoints = data.data_points.filter((p) => p[metric] != null);
if (apiPoints.length > 0) {
updated[metric] = {
...updated[metric],
data: updated[metric].data.map((d) => {
const match = apiPoints.find((p) => p.week === d.week);
return match && match[metric] != null
? { ...d, value: match[metric]! }
: d;
}),
};
}
}
setGrowthData(updated);
}
})
.catch(() => {/* keep standard data */})
.finally(() => setLoading(false));
}, []);
const currentData = growthData[selectedMetric];
// 检查是否有真实数据
const hasRealData = currentData.data.some(d => d.value != null);
// 生成动态评估结果
const generateAssessment = () => {
if (!hasRealData) {
return "暂无生长数据。上传B超报告后,系统会自动记录宝宝的生长数据并生成评估结果。";
}
const realDataPoints = currentData.data.filter(d => d.value != null);
const latestPoint = realDataPoints[realDataPoints.length - 1];
if (!latestPoint) {
return "暂无生长数据。上传B超报告后,系统会自动记录宝宝的生长数据并生成评估结果。";
}
const { value, lower, upper, week, standard } = latestPoint;
const metricName = currentData.name.split(" ")[0];
if (value! >= lower && value! <= upper) {
const deviation = Math.abs(value! - standard);
const deviationPercent = ((deviation / standard) * 100).toFixed(1);
if (deviation < standard * 0.05) {
return `宝宝的${metricName}发育非常好!孕${week}周的测量值为${value}${currentData.unit},与标准值${standard}${currentData.unit}非常接近,在正常范围内(${lower}-${upper}${currentData.unit})。继续保持健康的生活方式,按时产检。`;
} else {
return `宝宝的${metricName}发育良好!孕${week}周的测量值为${value}${currentData.unit},在正常范围内(${lower}-${upper}${currentData.unit}),与标准值${standard}${currentData.unit}相差约${deviationPercent}%。继续保持健康的生活方式,按时产检。`;
}
} else if (value! < lower) {
const gap = lower - value!;
return `宝宝的${metricName}略偏小。孕${week}周的测量值为${value}${currentData.unit},低于正常范围下限(${lower}${currentData.unit})约${gap}${currentData.unit}。建议加强营养摄入,多吃富含蛋白质的食物,并及时咨询产科医生,进行进一步评估。`;
} else {
const gap = value! - upper;
return `宝宝的${metricName}略偏大。孕${week}周的测量值为${value}${currentData.unit},高于正常范围上限(${upper}${currentData.unit})约${gap}${currentData.unit}。建议控制饮食,避免高糖高脂食物,并及时咨询产科医生,进行进一步评估。`;
}
};
const CustomTooltip = ({ active, payload }: any) => {
if (active && payload && payload.length) {
return (
<div className="bg-white p-3 rounded-xl shadow-sm border border-gray-100">
<p className="font-semibold text-gray-800 mb-2">
孕{payload[0].payload.week}周
</p>
<p className="text-sm text-gray-500">
标准值: {payload[0].payload.standard} {currentData.unit}
</p>
<p className="text-xs text-gray-400 mt-1">
正常范围: {payload[0].payload.lower}-{payload[0].payload.upper}{" "}
{currentData.unit}
</p>
</div>
);
}
return null;
};
return (
<div className="min-h-dvh bg-morandi-50">
{/* 顶部导航 */}
<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-gradient-to-br from-morandi-400 to-morandi-400 rounded-full flex items-center justify-center">
<TrendingUp className="w-5 h-5 text-white" />
</div>
<div>
<h1 className="font-semibold text-gray-800">胎儿生长曲线</h1>
<p className="text-xs text-gray-500">基于B超数据自动生成</p>
</div>
</div>
</div>
</div>
</div>
<div className="mx-auto p-4 space-y-6">
{/* 无B超数据时展示宝宝预期大小 */}
{!hasRealData && currentWeek > 0 && (() => {
const babyData = getBabyExpectedData(currentWeek);
const milestones = getGrowthMilestones(currentWeek);
const progress = Math.min(100, Math.max(0, ((currentWeek - 4) / 36) * 100));
if (!babyData) return null;
return (
<>
{/* 宝宝当前大小 */}
<motion.div
initial={{ opacity: 0, y: 20 }}
animate={{ opacity: 1, y: 0 }}
>
<Card className="bg-white rounded-2xl p-6 shadow-sm border-0 overflow-hidden relative">
<div className="absolute top-0 right-0 w-32 h-32 bg-gradient-to-bl from-morandi-100 to-transparent rounded-bl-full opacity-60" />
<div className="relative">
<h2 className="font-semibold text-gray-800 mb-4 flex items-center gap-2">
<Heart className="w-5 h-5 text-morandi-rose-400" />
宝宝现在有多大?
</h2>
<div className="text-center py-4">
<motion.div
initial={{ scale: 0.5, opacity: 0 }}
animate={{ scale: 1, opacity: 1 }}
transition={{ type: "spring", duration: 0.8 }}
className="text-7xl mb-4"
>
{babyData.emoji}
</motion.div>
<motion.p
initial={{ opacity: 0, y: 10 }}
animate={{ opacity: 1, y: 0 }}
transition={{ delay: 0.3 }}
className="text-lg text-gray-700 font-medium mb-1"
>
孕{currentWeek}周,宝宝像一个<span className="text-morandi-500 font-semibold">{babyData.fruit}</span>
</motion.p>
<p className="text-sm text-gray-400">{babyData.highlight}</p>
</div>
{/* 预期数据 */}
<div className="grid grid-cols-2 gap-3 mt-4">
<div className="bg-morandi-50 rounded-xl p-4 text-center">
<p className="text-xs text-gray-400 mb-1">预期身长</p>
<p className="text-xl font-semibold text-morandi-600">{babyData.length}</p>
</div>
<div className="bg-morandi-50 rounded-xl p-4 text-center">
<p className="text-xs text-gray-400 mb-1">预期体重</p>
<p className="text-xl font-semibold text-morandi-600">{babyData.weight}</p>
</div>
</div>
{/* 孕期进度 */}
<div className="mt-5">
<div className="flex items-center justify-between mb-2">
<span className="text-xs text-gray-400">孕期进度</span>
<span className="text-xs text-morandi-500 font-medium">{Math.round(progress)}%</span>
</div>
<div className="h-2.5 bg-morandi-50 rounded-full overflow-hidden">
<motion.div
className="h-full bg-gradient-to-r from-morandi-300 to-morandi-500 rounded-full"
initial={{ width: 0 }}
animate={{ width: `${progress}%` }}
transition={{ duration: 1.2, ease: "easeOut" }}
/>
</div>
<div className="flex justify-between mt-1">
<span className="text-[10px] text-gray-300">4周</span>
<span className="text-[10px] text-gray-300">40周</span>
</div>
</div>
</div>
</Card>
</motion.div>
{/* 成长里程碑时间线 */}
<motion.div
initial={{ opacity: 0, y: 20 }}
animate={{ opacity: 1, y: 0 }}
transition={{ delay: 0.15 }}
>
<Card className="bg-white rounded-2xl p-6 shadow-sm border-0">
<h3 className="font-semibold text-gray-800 mb-4">成长里程碑</h3>
<div className="space-y-0">
{milestones.map((m, i) => (
<div key={m.week} className="flex items-start gap-3">
{/* 时间线连接线 */}
<div className="flex flex-col items-center">
<div className={`w-8 h-8 rounded-full flex items-center justify-center text-sm flex-shrink-0 ${
m.passed
? "bg-morandi-400 text-white"
: currentWeek >= m.week - 2
? "bg-morandi-100 text-morandi-500 ring-2 ring-morandi-300"
: "bg-gray-100 text-gray-400"
}`}>
{m.emoji}
</div>
{i < milestones.length - 1 && (
<div className={`w-0.5 h-6 ${m.passed ? "bg-morandi-300" : "bg-gray-200"}`} />
)}
</div>
<div className="pt-1.5">
<p className={`text-sm font-medium ${m.passed ? "text-gray-700" : "text-gray-400"}`}>
{m.label}
</p>
<p className={`text-xs ${m.passed ? "text-morandi-500" : "text-gray-300"}`}>
孕{m.week}周
</p>
</div>
</div>
))}
</div>
</Card>
</motion.div>
{/* 上传B超提示 */}
<motion.div
initial={{ opacity: 0, y: 20 }}
animate={{ opacity: 1, y: 0 }}
transition={{ delay: 0.25 }}
>
<Card className="bg-gradient-to-r from-morandi-sage-50 to-morandi-50 border-morandi-200 rounded-2xl p-4">
<div className="flex items-start gap-3">
<Info className="w-5 h-5 text-morandi-sage-500 mt-0.5 flex-shrink-0" />
<div>
<h3 className="font-semibold text-gray-700 text-sm mb-1">查看精准发育数据</h3>
<p className="text-xs text-gray-500 leading-relaxed mb-3">
上传B超报告后,系统会自动提取胎儿双顶径、腹围、股骨长等数据,生成个性化的生长曲线,让你更直观地了解宝宝发育情况。
</p>
<Button
onClick={() => navigate("/camera")}
className="bg-gradient-to-r from-morandi-400 to-morandi-500"
size="sm"
>
上传B超报告
</Button>
</div>
</div>
</Card>
</motion.div>
</>
);
})()}
{/* 指标选择 - 只在有B超数据时显示 */}
{hasRealData && (
<motion.div
initial={{ opacity: 0, y: 20 }}
animate={{ opacity: 1, y: 0 }}
>
<div className="flex gap-3 overflow-x-auto pb-2">
<button
onClick={() => setSelectedMetric("bpd")}
className={`flex-shrink-0 px-6 py-3 rounded-2xl font-semibold text-sm transition-all ${
selectedMetric === "bpd"
? "bg-gradient-to-r from-morandi-400 to-morandi-400 text-white shadow-sm"
: "bg-white text-gray-600 hover:bg-gray-50"
}`}
>
双顶径
</button>
<button
onClick={() => setSelectedMetric("ac")}
className={`flex-shrink-0 px-6 py-3 rounded-2xl font-semibold text-sm transition-all ${
selectedMetric === "ac"
? "bg-gradient-to-r from-morandi-400 to-morandi-400 text-white shadow-sm"
: "bg-white text-gray-600 hover:bg-gray-50"
}`}
>
腹围
</button>
<button
onClick={() => setSelectedMetric("fl")}
className={`flex-shrink-0 px-6 py-3 rounded-2xl font-semibold text-sm transition-all ${
selectedMetric === "fl"
? "bg-gradient-to-r from-morandi-rose-400 to-morandi-rose-300 text-white shadow-sm"
: "bg-white text-gray-600 hover:bg-gray-50"
}`}
>
股骨长
</button>
</div>
</motion.div>
)}
{/* 有B超数据时显示指标详情、曲线图、数据表格 */}
{hasRealData && (
<>
{/* 指标说明 */}
<motion.div
initial={{ opacity: 0, y: 20 }}
animate={{ opacity: 1, y: 0 }}
transition={{ delay: 0.1 }}
>
<Card className="bg-morandi-slate-50 border-morandi-200 rounded-2xl p-4">
<div className="flex items-start gap-3">
<Info className="w-5 h-5 text-morandi-slate-500 mt-0.5 flex-shrink-0" />
<div>
<h3 className="font-semibold text-gray-800 mb-1">
{currentData.name}
</h3>
<p className="text-sm text-gray-600">
{currentData.description}
</p>
</div>
</div>
</Card>
</motion.div>
{/* 生长曲线图 */}
<motion.div
initial={{ opacity: 0, y: 20 }}
animate={{ opacity: 1, y: 0 }}
transition={{ delay: 0.2 }}
>
<Card className="bg-white rounded-2xl p-6 shadow-sm border-0">
<div className="flex items-center justify-between mb-6">
<h2 className="font-semibold text-gray-800">生长趋势</h2>
<div className="flex items-center gap-4 text-sm">
<div className="flex items-center gap-2">
<div className="w-3 h-3 bg-gray-300 rounded-full"></div>
<span className="text-gray-600">标准值</span>
</div>
<div className="flex items-center gap-2">
<div className="w-3 h-3 bg-morandi-slate-200 rounded-full"></div>
<span className="text-gray-600">正常范围</span>
</div>
</div>
</div>
<ResponsiveContainer width="100%" height={300}>
<AreaChart data={currentData.data}>
<defs>
<linearGradient id="colorRange" x1="0" y1="0" x2="0" y2="1">
<stop offset="5%" stopColor="#93c5fd" stopOpacity={0.3} />
<stop offset="95%" stopColor="#93c5fd" stopOpacity={0.1} />
</linearGradient>
</defs>
<CartesianGrid strokeDasharray="3 3" stroke="#e5e7eb" />
<XAxis
dataKey="week"
label={{ value: "孕周", position: "insideBottom", offset: -5 }}
stroke="#9ca3af"
/>
<YAxis
label={{
value: `${currentData.unit}`,
angle: -90,
position: "insideLeft",
}}
stroke="#9ca3af"
/>
<Tooltip content={<CustomTooltip />} />
<Area
type="monotone"
dataKey="upper"
stroke="none"
fill="url(#colorRange)"
/>
<Area
type="monotone"
dataKey="lower"
stroke="none"
fill="#ffffff"
/>
<Line
type="monotone"
dataKey="standard"
stroke="#d1d5db"
strokeWidth={2}
dot={false}
strokeDasharray="5 5"
/>
{/* 完全移除实际值线条 */}
</AreaChart>
</ResponsiveContainer>
</Card>
</motion.div>
{/* 数据表格 */}
<motion.div
initial={{ opacity: 0, y: 20 }}
animate={{ opacity: 1, y: 0 }}
transition={{ delay: 0.3 }}
>
<Card className="bg-white rounded-2xl p-6 shadow-sm border-0">
<h3 className="font-semibold text-gray-800 mb-4">详细数据</h3>
<div className="overflow-x-auto">
<table className="w-full">
<thead>
<tr className="border-b border-gray-200">
<th className="text-left py-3 text-sm font-semibold text-gray-600">
孕周
</th>
<th className="text-center py-3 text-sm font-semibold text-gray-600">
实际值
</th>
<th className="text-center py-3 text-sm font-semibold text-gray-600">
标准值
</th>
<th className="text-center py-3 text-sm font-semibold text-gray-600">
正常范围
</th>
<th className="text-center py-3 text-sm font-semibold text-gray-600">
状态
</th>
</tr>
</thead>
<tbody>
{currentData.data.map((row, index) => {
const isNormal =
row.value != null && row.value >= row.lower && row.value <= row.upper;
return (
<tr key={index} className="border-b border-gray-100">
<td className="py-3 text-sm text-gray-700">
{row.week}周
</td>
<td className="text-center py-3 text-sm font-semibold text-morandi-500">
{row.value != null ? `${row.value} ${currentData.unit}` : '-'}
</td>
<td className="text-center py-3 text-sm text-gray-600">
{row.standard} {currentData.unit}
</td>
<td className="text-center py-3 text-sm text-gray-500">
{row.lower}-{row.upper} {currentData.unit}
</td>
<td className="text-center py-3">
{row.value != null ? (
<Badge
className={`${
isNormal
? "bg-morandi-sage-100 text-morandi-sage-600"
: "bg-morandi-rose-100 text-morandi-600"
} hover:bg-current`}
>
{isNormal ? "正常" : "关注"}
</Badge>
) : (
<span className="text-gray-400 text-xs">-</span>
)}
</td>
</tr>
);
})}
</tbody>
</table>
</div>
</Card>
</motion.div>
{/* 医生建议 */}
<motion.div
initial={{ opacity: 0, y: 20 }}
animate={{ opacity: 1, y: 0 }}
transition={{ delay: 0.4 }}
>
<Card className="bg-gradient-to-r from-morandi-sage-100 to-morandi-sage-100 border-morandi-sage-200 rounded-2xl p-4">
<h3 className="font-semibold text-gray-800 mb-2 flex items-center gap-2">
<TrendingUp className="w-5 h-5 text-morandi-sage-500" />
评估结果
</h3>
<p className="text-sm text-gray-700 leading-relaxed">
{generateAssessment()}
</p>
</Card>
</motion.div>
</>
)}
</div>
</div>
);
}