chore: 初始化项目结构并添加基础配置文件
添加后端API和前端项目的初始目录结构,包括应用层模块、工具模块和模型定义 配置前后端的.dockerignore文件以排除开发环境文件 添加前端样式文件,包括字体、Tailwind CSS和主题配置 初始化Python虚拟环境中的类型标记文件
This commit is contained in:
413
前端-孕期报告解读-figma/src/app/pages/OnboardingPage.tsx
Normal file
413
前端-孕期报告解读-figma/src/app/pages/OnboardingPage.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
Reference in New Issue
Block a user