Files
muyingbaogaojiedu/前端-孕期报告解读-figma/src/app/pages/OnboardingPage.tsx
shutiaozhao127-boop cfdeeb5e41 chore: 初始化项目结构并添加基础配置文件
添加后端API和前端项目的初始目录结构,包括应用层模块、工具模块和模型定义
配置前后端的.dockerignore文件以排除开发环境文件
添加前端样式文件,包括字体、Tailwind CSS和主题配置
初始化Python虚拟环境中的类型标记文件
2026-04-01 16:55:24 +08:00

414 lines
16 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 } 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>
);
}