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,413 @@
import { useState } from "react";
import { useNavigate } from "react-router";
import { motion } from "motion/react";
import { ChevronUp, ChevronDown, Loader2 } from "lucide-react";
import { Input } from "../components/ui/input";
import { useUser, calcPregnancyWeek, calcDueDate } from "../context/UserContext";
import { submitOnboarding } from "../services/api";
type DateMode = "due_date" | "last_period";
type MultiplePregnancy = "单胎" | "双胎" | "三胎+";
type PregnancyCount = 1 | 2 | 3;
const MEDICAL_CONDITIONS = [
"糖尿病 / 妊娠糖尿病史",
"高血压 / 妊娠高血压史",
"甲状腺疾病",
];
export function OnboardingPage() {
const navigate = useNavigate();
const { updateUser } = useUser();
const [isSubmitting, setIsSubmitting] = useState(false);
const [error, setError] = useState("");
// 基本信息
const [dateMode, setDateMode] = useState<DateMode>("due_date");
const [dueDate, setDueDate] = useState("");
const [lastPeriodDate, setLastPeriodDate] = useState("");
// 特殊情况
const [showSpecial, setShowSpecial] = useState(false);
const [age, setAge] = useState("");
const [multiplePregnancy, setMultiplePregnancy] = useState<MultiplePregnancy>("单胎");
const [medicalHistory, setMedicalHistory] = useState<string[]>([]);
const [noMedicalHistory, setNoMedicalHistory] = useState(false);
// 补充信息
const [showExtra, setShowExtra] = useState(false);
const [preWeight, setPreWeight] = useState("");
const [height, setHeight] = useState("");
const [pregnancyCount, setPregnancyCount] = useState<PregnancyCount>(1);
// 计算孕周和预产期(用于显示)
const computedInfo = (() => {
if (dateMode === "last_period" && lastPeriodDate) {
const { weeks, days } = calcPregnancyWeek(lastPeriodDate);
const due = calcDueDate(lastPeriodDate);
if (weeks >= 0 && weeks <= 42) {
return { weeks, days, dueDate: due, valid: true };
}
}
if (dateMode === "due_date" && dueDate) {
// 从预产期反推末次月经:预产期 - 280天
const dueDateObj = new Date(dueDate);
const lmpDate = new Date(dueDateObj.getTime() - 280 * 24 * 60 * 60 * 1000);
const lmpStr = lmpDate.toISOString().split("T")[0];
const { weeks, days } = calcPregnancyWeek(lmpStr);
if (weeks >= 0 && weeks <= 42) {
return { weeks, days, dueDate, lmpDate: lmpStr, valid: true };
}
}
return { weeks: 0, days: 0, dueDate: "", valid: false };
})();
const canSubmit = dateMode === "due_date" ? !!dueDate : !!lastPeriodDate;
const toggleMedicalCondition = (condition: string) => {
setNoMedicalHistory(false);
setMedicalHistory((prev) =>
prev.includes(condition) ? prev.filter((c) => c !== condition) : [...prev, condition]
);
};
const toggleNoMedicalHistory = () => {
setNoMedicalHistory(!noMedicalHistory);
if (!noMedicalHistory) {
setMedicalHistory([]);
}
};
const handleSubmit = async () => {
setError("");
setIsSubmitting(true);
try {
// 确定末次月经日期
let lmpDate = lastPeriodDate;
let dueDateStr = dueDate;
if (dateMode === "due_date" && dueDate) {
const dueDateObj = new Date(dueDate);
lmpDate = new Date(dueDateObj.getTime() - 280 * 24 * 60 * 60 * 1000).toISOString().split("T")[0];
dueDateStr = dueDate;
} else if (dateMode === "last_period" && lastPeriodDate) {
dueDateStr = calcDueDate(lastPeriodDate);
lmpDate = lastPeriodDate;
}
const { weeks, days } = calcPregnancyWeek(lmpDate);
const payload: Record<string, unknown> = {
last_period_date: lmpDate,
due_date: dueDateStr,
pregnancy_week: weeks,
pregnancy_day: days,
multiple_pregnancy: multiplePregnancy,
pregnancy_count: pregnancyCount,
};
if (age) payload.age = parseInt(age);
if (medicalHistory.length > 0) payload.medical_history = medicalHistory;
if (preWeight) payload.pre_pregnancy_weight = parseFloat(preWeight);
if (height) payload.height = parseFloat(height);
await submitOnboarding(payload as Parameters<typeof submitOnboarding>[0]);
updateUser({
lastPeriodDate: lmpDate,
dueDate: dueDateStr,
currentWeek: weeks,
currentDay: days,
age: age ? parseInt(age) : null,
prePregnancyWeight: preWeight ? parseFloat(preWeight) : null,
height: height ? parseFloat(height) : null,
medicalHistory,
isOnboarded: true,
});
navigate("/home");
} catch (err: unknown) {
const message = err instanceof Error ? err.message : "保存失败,请重试";
setError(message);
} finally {
setIsSubmitting(false);
}
};
return (
<div className="min-h-dvh bg-morandi-50">
<div className="px-4 pt-6 pb-32">
{/* 页面标题 */}
<h1 className="text-xl font-bold text-gray-800 mb-1">完善孕期信息</h1>
<p className="text-sm text-gray-400 mb-5">帮助我们更准确地解读您的报告</p>
{/* 基本信息 */}
<section className="mb-4 bg-white rounded-3xl shadow-sm p-5">
<h2 className="text-base font-semibold text-gray-800 mb-4">
基本信息 <span className="text-red-400">*</span>
</h2>
{/* 日期方式切换 */}
<p className="text-sm text-gray-500 mb-2">请选择一种方式</p>
<div className="flex gap-3 mb-4">
<button
onClick={() => setDateMode("due_date")}
className={`flex-1 h-11 rounded-full border-2 text-sm font-medium transition-all ${
dateMode === "due_date"
? "border-morandi-300 text-morandi-500 bg-morandi-50"
: "border-gray-200 text-gray-500 hover:border-gray-300"
}`}
>
我知道预产期
</button>
<button
onClick={() => setDateMode("last_period")}
className={`flex-1 h-11 rounded-full border-2 text-sm font-medium transition-all ${
dateMode === "last_period"
? "border-morandi-300 text-morandi-500 bg-morandi-50"
: "border-gray-200 text-gray-500 hover:border-gray-300"
}`}
>
我知道末次月经
</button>
</div>
{/* 日期输入 */}
{dateMode === "due_date" ? (
<div>
<p className="text-sm text-gray-600 mb-2">预产期</p>
<Input
type="date"
value={dueDate}
onChange={(e) => setDueDate(e.target.value)}
className="h-12 rounded-xl border-2 border-gray-200 focus:border-morandi-200 bg-white"
/>
</div>
) : (
<div>
<p className="text-sm text-gray-600 mb-2">末次月经日期</p>
<Input
type="date"
value={lastPeriodDate}
onChange={(e) => setLastPeriodDate(e.target.value)}
className="h-12 rounded-xl border-2 border-gray-200 focus:border-morandi-200 bg-white"
/>
</div>
)}
{/* 孕周计算结果 */}
{computedInfo.valid && (
<motion.div
initial={{ opacity: 0, y: -8 }}
animate={{ opacity: 1, y: 0 }}
className="mt-3 p-3 bg-morandi-50 rounded-xl"
>
<p className="text-sm text-morandi-400">
当前孕 <span className="font-semibold">{computedInfo.weeks}</span> 周{" "}
<span className="font-semibold">{computedInfo.days}</span> 天
{dateMode === "last_period" && computedInfo.dueDate && (
<span className="ml-2">
· 预产期 {new Date(computedInfo.dueDate).toLocaleDateString("zh-CN")}
</span>
)}
</p>
</motion.div>
)}
</section>
{/* 特殊情况(选填) */}
<section className="mb-4 bg-white rounded-3xl shadow-sm p-5">
<button
onClick={() => setShowSpecial(!showSpecial)}
className={`flex items-center justify-between w-full ${showSpecial ? "mb-4" : ""}`}
>
<h2 className="text-base font-semibold text-gray-800">特殊情况(选填)</h2>
<span className="flex items-center text-xs text-gray-400">
影响解读准确性
{showSpecial ? (
<ChevronUp className="w-4 h-4 ml-1" />
) : (
<ChevronDown className="w-4 h-4 ml-1" />
)}
</span>
</button>
{showSpecial && (
<motion.div
initial={{ opacity: 0, height: 0 }}
animate={{ opacity: 1, height: "auto" }}
className="space-y-5"
>
{/* 年龄 */}
<div>
<p className="text-sm text-gray-600 mb-2">年龄</p>
<Input
type="number"
placeholder="请输入年龄"
value={age}
onChange={(e) => setAge(e.target.value)}
className="h-12 rounded-xl border-2 border-gray-200 focus:border-morandi-200 bg-white"
/>
<p className="text-xs text-amber-500 mt-2 flex items-start">
<span className="mr-1">💡</span>
35岁以上属于高龄孕妇,某些指标的风险评估会不同
</p>
</div>
{/* 多胎妊娠 */}
<div>
<p className="text-sm text-gray-600 mb-2">是否多胎妊娠?</p>
<div className="flex gap-3">
{(["单胎", "双胎", "三胎+"] as MultiplePregnancy[]).map((opt) => (
<button
key={opt}
onClick={() => setMultiplePregnancy(opt)}
className={`flex-1 h-11 rounded-full border-2 text-sm font-medium transition-all ${
multiplePregnancy === opt
? "border-morandi-300 text-morandi-500 bg-morandi-50"
: "border-gray-200 text-gray-500 hover:border-gray-300"
}`}
>
{opt}
</button>
))}
</div>
</div>
{/* 疾病史 */}
<div>
<p className="text-sm text-gray-600 mb-2">是否有以下疾病史?(可多选)</p>
<div className="space-y-2">
{MEDICAL_CONDITIONS.map((condition) => (
<button
key={condition}
onClick={() => toggleMedicalCondition(condition)}
className={`w-full h-12 px-4 rounded-xl border-2 text-left text-sm font-medium transition-all ${
medicalHistory.includes(condition)
? "border-morandi-300 text-morandi-500 bg-morandi-50"
: "border-gray-200 text-gray-600 hover:border-gray-300 bg-white"
}`}
>
{condition}
</button>
))}
<button
onClick={toggleNoMedicalHistory}
className={`w-full h-12 px-4 rounded-xl border-2 text-left text-sm font-medium transition-all ${
noMedicalHistory
? "border-morandi-300 text-morandi-500 bg-morandi-50"
: "border-gray-200 text-gray-600 hover:border-gray-300 bg-white"
}`}
>
以上都没有 ✓
</button>
</div>
</div>
</motion.div>
)}
</section>
{/* 补充信息(选填) */}
<section className="mb-4 bg-white rounded-3xl shadow-sm p-5">
<button
onClick={() => setShowExtra(!showExtra)}
className={`flex items-center justify-between w-full ${showExtra ? "mb-4" : ""}`}
>
<h2 className="text-base font-semibold text-gray-800">补充信息(选填)</h2>
<span className="flex items-center text-xs text-gray-400">
用于BMI计算
{showExtra ? (
<ChevronUp className="w-4 h-4 ml-1" />
) : (
<ChevronDown className="w-4 h-4 ml-1" />
)}
</span>
</button>
{showExtra && (
<motion.div
initial={{ opacity: 0, height: 0 }}
animate={{ opacity: 1, height: "auto" }}
className="space-y-5"
>
{/* 孕前体重 */}
<div>
<p className="text-sm text-gray-600 mb-2">孕前体重 (kg)</p>
<Input
type="number"
placeholder="请输入"
value={preWeight}
onChange={(e) => setPreWeight(e.target.value)}
className="h-12 rounded-xl border-2 border-gray-200 focus:border-morandi-200 bg-white"
/>
</div>
{/* 身高 */}
<div>
<p className="text-sm text-gray-600 mb-2">身高 (cm)</p>
<Input
type="number"
placeholder="请输入"
value={height}
onChange={(e) => setHeight(e.target.value)}
className="h-12 rounded-xl border-2 border-gray-200 focus:border-morandi-200 bg-white"
/>
</div>
{/* 第几次怀孕 */}
<div>
<p className="text-sm text-gray-600 mb-2">第几次怀孕</p>
<div className="flex gap-3">
{([1, 2, 3] as PregnancyCount[]).map((count) => (
<button
key={count}
onClick={() => setPregnancyCount(count)}
className={`flex-1 h-11 rounded-full border-2 text-sm font-medium transition-all ${
pregnancyCount === count
? "border-morandi-300 text-morandi-500 bg-morandi-50"
: "border-gray-200 text-gray-500 hover:border-gray-300"
}`}
>
{count === 3 ? "第3次+" : `第${count}次`}
</button>
))}
</div>
</div>
</motion.div>
)}
</section>
</div>
{/* 错误提示 */}
{error && (
<motion.div
initial={{ opacity: 0, y: 10 }}
animate={{ opacity: 1, y: 0 }}
className="fixed bottom-24 left-4 right-4 mx-auto"
>
<p className="text-sm text-red-500 bg-red-50 px-4 py-3 rounded-xl text-center">
{error}
</p>
</motion.div>
)}
{/* 底部按钮 */}
<div className="fixed bottom-0 left-0 right-0 bg-white/80 backdrop-blur-sm border-t border-gray-100 px-4 py-4 pb-safe z-10">
<div className="max-w-md mx-auto">
<button
onClick={handleSubmit}
disabled={!canSubmit || isSubmitting}
className="w-full h-12 rounded-full bg-gradient-to-r from-morandi-400 to-morandi-500 text-white font-semibold text-base shadow-lg disabled:opacity-50 disabled:cursor-not-allowed flex items-center justify-center"
>
{isSubmitting ? (
<>
<Loader2 className="w-4 h-4 mr-2 animate-spin" />
保存中...
</>
) : (
"完成建档"
)}
</button>
</div>
</div>
</div>
);
}