- 后端 UserProfileUpdate 新增 avatar_url 字段 - LoginPage 检测 URL 参数 name/avatar 并调用 updateUserProfile - ProfilePage 头像卡片显示真实头像(有 avatar_url 时) - ProfilePage 增加 基本信息-姓名 编辑字段
515 lines
18 KiB
TypeScript
515 lines
18 KiB
TypeScript
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";
|
||
import { isDebug, getDebugLogs } from "../services/debug";
|
||
|
||
interface UserInfo {
|
||
name: string;
|
||
age: number;
|
||
phone: string;
|
||
email: string;
|
||
address: string;
|
||
avatar_url: string | null;
|
||
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: "",
|
||
avatar_url: user.avatar_url || null,
|
||
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>
|
||
);
|
||
|
||
// 🐞 调试面板(VITE_DEBUG=true 时显示)
|
||
const [debugLog] = useState<string[]>(() => isDebug() ? getDebugLogs() : []);
|
||
const [showDebug, setShowDebug] = useState(false);
|
||
|
||
return (
|
||
<div className="min-h-dvh bg-morandi-50 pb-20">
|
||
{/* 🐞 调试面板 */}
|
||
{debugLog.length > 0 && (
|
||
<div className="bg-black/80 text-white text-xs font-mono p-3">
|
||
<button onClick={() => setShowDebug(!showDebug)} className="text-yellow-400 font-bold mb-1">
|
||
🐞 {showDebug ? '收起' : '展开'}调试日志 ({debugLog.length}条)
|
||
</button>
|
||
{showDebug && debugLog.map((line, i) => (
|
||
<div key={i} className="text-green-300 break-all">{line}</div>
|
||
))}
|
||
</div>
|
||
)}
|
||
{/* 顶部导航 */}
|
||
<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">
|
||
{userInfo.avatar_url ? (
|
||
<img src={userInfo.avatar_url} alt="头像" className="w-20 h-20 rounded-full object-cover" />
|
||
) : (
|
||
<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.05 }}
|
||
>
|
||
<h2 className="text-base font-semibold text-gray-800 mb-3 flex items-center gap-2">
|
||
<User 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.name}
|
||
field="name"
|
||
icon={User}
|
||
/>
|
||
</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>
|
||
);
|
||
} |