feat: 初始化孕育无忧全栈项目基础代码结构
完成项目初始搭建,包含后端FastAPI+MongoDB服务、前端React+Vite项目的完整基础结构,添加各类配置文件与依赖清单,涵盖核心业务初始框架。
This commit is contained in:
473
frontend/src/app/pages/ProfilePage.tsx
Normal file
473
frontend/src/app/pages/ProfilePage.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
Reference in New Issue
Block a user