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,473 @@
import { useState, useEffect } from "react";
import { useNavigate } from "react-router";
import { motion } from "motion/react";
import {
ChevronLeft,
User,
Calendar,
Heart,
Phone,
Mail,
MapPin,
Edit2,
Check,
X,
Camera,
Baby,
Activity,
AlertCircle,
Shield,
Bell,
LogOut,
} from "lucide-react";
import { Button } from "../components/ui/button";
import { Card } from "../components/ui/card";
import { Badge } from "../components/ui/badge";
import { useUser, getPregnancyStage } from "../context/UserContext";
interface UserInfo {
name: string;
age: number;
phone: string;
email: string;
address: string;
lastPeriod: string;
dueDate: string;
currentWeek: number;
currentDay: number;
height: number;
prePregnancyWeight: number;
currentWeight: number;
bloodType: string;
allergies: string[];
medicalHistory: string[];
emergencyContact: {
name: string;
relation: string;
phone: string;
};
}
export function ProfilePage() {
const navigate = useNavigate();
const { user, updateUserRemote, logout } = useUser();
const [isEditing, setIsEditing] = useState<string | null>(null);
const [userInfo, setUserInfo] = useState<UserInfo>({
name: user.name || "未设置",
age: user.age || 0,
phone: user.phone || "未设置",
email: "",
address: "",
lastPeriod: user.lastPeriodDate || "",
dueDate: user.dueDate || "",
currentWeek: user.currentWeek,
currentDay: user.currentDay,
height: user.height || 0,
prePregnancyWeight: user.prePregnancyWeight || 0,
currentWeight: user.currentWeight || 0,
bloodType: user.bloodType || "未设置",
allergies: user.allergies.length ? user.allergies : ["无"],
medicalHistory: user.medicalHistory.length ? user.medicalHistory : ["无重大病史"],
emergencyContact: {
name: user.emergencyContact.name || "未设置",
relation: user.emergencyContact.relation || "",
phone: user.emergencyContact.phone || "",
},
});
const [editValues, setEditValues] = useState<Partial<UserInfo>>({});
const handleEdit = (field: string) => {
setIsEditing(field);
setEditValues({ [field]: (userInfo as any)[field] });
};
const handleSave = (field: string) => {
const updated = { ...userInfo, ...(editValues as any) };
setUserInfo(updated);
setIsEditing(null);
setEditValues({});
// Sync back to global context + backend
updateUserRemote({
name: updated.name,
age: updated.age,
phone: updated.phone,
lastPeriodDate: updated.lastPeriod,
height: updated.height,
prePregnancyWeight: updated.prePregnancyWeight,
currentWeight: updated.currentWeight,
bloodType: updated.bloodType,
allergies: updated.allergies,
medicalHistory: updated.medicalHistory,
emergencyContact: updated.emergencyContact,
});
};
const handleCancel = () => {
setIsEditing(null);
setEditValues({});
};
const calculateBMI = () => {
const heightInMeters = userInfo.height / 100;
const bmi = userInfo.currentWeight / (heightInMeters * heightInMeters);
return bmi.toFixed(1);
};
const calculateWeightGain = () => {
return (userInfo.currentWeight - userInfo.prePregnancyWeight).toFixed(1);
};
const InfoItem = ({
label,
value,
field,
editable = true,
type = "text",
icon: Icon,
}: {
label: string;
value: string | number;
field: string;
editable?: boolean;
type?: string;
icon?: any;
}) => (
<div className="flex items-center justify-between py-3 border-b border-gray-100 last:border-0">
<div className="flex items-center gap-3">
{Icon && (
<div className="w-8 h-8 bg-morandi-100 rounded-lg flex items-center justify-center">
<Icon className="w-4 h-4 text-morandi-500" />
</div>
)}
<div>
<div className="text-sm text-gray-500">{label}</div>
{isEditing === field ? (
<input
type={type}
value={(editValues as any)[field] ?? value}
onChange={(e) =>
setEditValues({ ...editValues, [field]: e.target.value })
}
className="mt-1 px-2 py-1 border border-morandi-200 rounded-lg focus:outline-none focus:ring-2 focus:ring-morandi-500"
autoFocus
/>
) : (
<div className="font-semibold text-gray-800">{value}</div>
)}
</div>
</div>
{editable && (
<div className="flex items-center gap-2">
{isEditing === field ? (
<>
<button
onClick={() => handleSave(field)}
className="w-8 h-8 bg-morandi-sage-100 rounded-full flex items-center justify-center hover:bg-morandi-sage-200"
>
<Check className="w-4 h-4 text-morandi-sage-500" />
</button>
<button
onClick={handleCancel}
className="w-8 h-8 bg-gray-100 rounded-full flex items-center justify-center hover:bg-gray-200"
>
<X className="w-4 h-4 text-gray-600" />
</button>
</>
) : (
<button
onClick={() => handleEdit(field)}
className="w-8 h-8 bg-gray-100 rounded-full flex items-center justify-center hover:bg-gray-200"
>
<Edit2 className="w-4 h-4 text-gray-600" />
</button>
)}
</div>
)}
</div>
);
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>
<h1 className="font-semibold text-gray-800">个人中心</h1>
<p className="text-xs text-gray-500">管理您的个人信息</p>
</div>
</div>
</div>
</div>
<div className="mx-auto p-4 space-y-6">
{/* 个人头像卡片 */}
<motion.div
initial={{ opacity: 0, y: 20 }}
animate={{ opacity: 1, y: 0 }}
>
<Card className="bg-gradient-to-br from-morandi-400 to-morandi-500 border-0 text-white p-6 rounded-3xl shadow-sm">
<div className="flex items-center gap-4">
<div className="relative">
<div className="w-20 h-20 bg-white/20 backdrop-blur-sm rounded-full flex items-center justify-center">
<User className="w-10 h-10 text-white" />
</div>
<button className="absolute bottom-0 right-0 w-6 h-6 bg-white rounded-full flex items-center justify-center shadow-sm">
<Camera className="w-3 h-3 text-morandi-500" />
</button>
</div>
<div className="flex-1">
<h2 className="text-2xl font-semibold mb-1">{userInfo.name}</h2>
<p className="text-white/90 text-sm">
{userInfo.age}岁 · {userInfo.bloodType}
</p>
<p className="text-white/80 text-sm mt-1">
孕{userInfo.currentWeek}周+{userInfo.currentDay}天
</p>
</div>
</div>
</Card>
</motion.div>
{/* 孕期信息 */}
<motion.div
initial={{ opacity: 0, y: 20 }}
animate={{ opacity: 1, y: 0 }}
transition={{ delay: 0.1 }}
>
<h2 className="text-base font-semibold text-gray-800 mb-3 flex items-center gap-2">
<Baby className="w-5 h-5 text-morandi-500" />
孕期信息
</h2>
<Card className="bg-white rounded-2xl p-5 shadow-sm border-0">
<InfoItem
label="末次月经"
value={userInfo.lastPeriod}
field="lastPeriod"
type="date"
icon={Calendar}
/>
<InfoItem
label="预产期"
value={userInfo.dueDate}
field="dueDate"
type="date"
icon={Heart}
/>
<div className="flex items-center justify-between py-3">
<div className="flex items-center gap-3">
<div className="w-8 h-8 bg-morandi-100 rounded-lg flex items-center justify-center">
<Activity className="w-4 h-4 text-morandi-500" />
</div>
<div>
<div className="text-sm text-gray-500">当前孕周</div>
<div className="font-semibold text-gray-800">
{userInfo.currentWeek}周+{userInfo.currentDay}天
</div>
</div>
</div>
<Badge className="bg-morandi-100 text-morandi-500 hover:bg-morandi-100">
{getPregnancyStage(userInfo.currentWeek)}
</Badge>
</div>
</Card>
</motion.div>
{/* 健康数据 */}
<motion.div
initial={{ opacity: 0, y: 20 }}
animate={{ opacity: 1, y: 0 }}
transition={{ delay: 0.2 }}
>
<h2 className="text-base font-semibold text-gray-800 mb-3 flex items-center gap-2">
<Activity className="w-5 h-5 text-morandi-slate-500" />
健康数据
</h2>
<Card className="bg-white rounded-2xl p-5 shadow-sm border-0">
<InfoItem
label="身高"
value={`${userInfo.height} cm`}
field="height"
type="number"
/>
<InfoItem
label="孕前体重"
value={`${userInfo.prePregnancyWeight} kg`}
field="prePregnancyWeight"
type="number"
/>
<InfoItem
label="当前体重"
value={`${userInfo.currentWeight} kg`}
field="currentWeight"
type="number"
/>
<div className="grid grid-cols-2 gap-4 mt-4 pt-4 border-t border-gray-100">
<div className="bg-morandi-slate-50 rounded-xl p-3">
<div className="text-xs text-morandi-slate-500 mb-1">当前BMI</div>
<div className="text-2xl font-semibold text-morandi-500">
{calculateBMI()}
</div>
<div className="text-xs text-morandi-slate-500 mt-1">正常范围</div>
</div>
<div className="bg-morandi-sage-100 rounded-xl p-3">
<div className="text-xs text-morandi-sage-500 mb-1">体重增长</div>
<div className="text-2xl font-semibold text-morandi-sage-600">
+{calculateWeightGain()} kg
</div>
<div className="text-xs text-morandi-sage-500 mt-1">符合预期</div>
</div>
</div>
</Card>
</motion.div>
{/* 联系方式 */}
<motion.div
initial={{ opacity: 0, y: 20 }}
animate={{ opacity: 1, y: 0 }}
transition={{ delay: 0.3 }}
>
<h2 className="text-base font-semibold text-gray-800 mb-3 flex items-center gap-2">
<Phone className="w-5 h-5 text-morandi-600" />
联系方式
</h2>
<Card className="bg-white rounded-2xl p-5 shadow-sm border-0">
<InfoItem
label="手机号"
value={userInfo.phone}
field="phone"
icon={Phone}
/>
<InfoItem
label="邮箱"
value={userInfo.email}
field="email"
icon={Mail}
/>
<InfoItem
label="居住地"
value={userInfo.address}
field="address"
icon={MapPin}
/>
</Card>
</motion.div>
{/* 医疗信息 */}
<motion.div
initial={{ opacity: 0, y: 20 }}
animate={{ opacity: 1, y: 0 }}
transition={{ delay: 0.4 }}
>
<h2 className="text-base font-semibold text-gray-800 mb-3 flex items-center gap-2">
<AlertCircle className="w-5 h-5 text-red-600" />
医疗信息
</h2>
<Card className="bg-white rounded-2xl p-5 shadow-sm border-0">
<div className="space-y-4">
<div>
<div className="text-sm text-gray-500 mb-2">血型</div>
<Badge className="bg-red-100 text-red-700 hover:bg-red-100">
{userInfo.bloodType}
</Badge>
</div>
<div>
<div className="text-sm text-gray-500 mb-2">药物过敏史</div>
<div className="flex flex-wrap gap-2">
{userInfo.allergies.map((allergy, index) => (
<Badge
key={index}
className="bg-morandi-rose-100 text-morandi-rose-400 hover:bg-morandi-rose-100"
>
{allergy}
</Badge>
))}
<button className="px-3 py-1 border border-dashed border-gray-300 rounded-full text-sm text-gray-500 hover:border-morandi-200 hover:text-morandi-500">
+ 添加
</button>
</div>
</div>
<div>
<div className="text-sm text-gray-500 mb-2">既往病史</div>
<div className="flex flex-wrap gap-2">
{userInfo.medicalHistory.map((history, index) => (
<Badge
key={index}
className="bg-morandi-50 text-morandi-500 hover:bg-morandi-50"
>
{history}
</Badge>
))}
<button className="px-3 py-1 border border-dashed border-gray-300 rounded-full text-sm text-gray-500 hover:border-morandi-200 hover:text-morandi-500">
+ 添加
</button>
</div>
</div>
</div>
</Card>
</motion.div>
{/* 紧急联系人 */}
<motion.div
initial={{ opacity: 0, y: 20 }}
animate={{ opacity: 1, y: 0 }}
transition={{ delay: 0.5 }}
>
<h2 className="text-base font-semibold text-gray-800 mb-3 flex items-center gap-2">
<Shield className="w-5 h-5 text-morandi-sage-500" />
紧急联系人
</h2>
<Card className="bg-white rounded-2xl p-5 shadow-sm border-0">
<div className="space-y-3">
<div className="flex items-center justify-between">
<div>
<div className="text-sm text-gray-500">姓名</div>
<div className="font-semibold text-gray-800">
{userInfo.emergencyContact.name}
</div>
</div>
<Badge className="bg-morandi-sage-100 text-morandi-sage-600 hover:bg-morandi-sage-100">
{userInfo.emergencyContact.relation}
</Badge>
</div>
<div>
<div className="text-sm text-gray-500">联系电话</div>
<div className="font-semibold text-gray-800">
{userInfo.emergencyContact.phone}
</div>
</div>
</div>
</Card>
</motion.div>
{/* 退出登录 */}
<motion.div
initial={{ opacity: 0, y: 20 }}
animate={{ opacity: 1, y: 0 }}
transition={{ delay: 0.7 }}
>
<Button
onClick={() => {
logout();
navigate("/");
}}
variant="outline"
className="w-full border-red-200 text-red-600 hover:bg-red-50 hover:text-red-700 hover:border-red-300 rounded-2xl py-6"
>
<LogOut className="w-5 h-5 mr-2" />
退出登录
</Button>
</motion.div>
<div className="h-8"></div>
</div>
</div>
);
}