Files
app_03_pregnancy/frontend/src/app/pages/ProfilePage.tsx
Karlcarlo bceb4f4619 feat: 支持从小程序获取微信头像和昵称同步到 H5
- 后端 UserProfileUpdate 新增 avatar_url 字段
- LoginPage 检测 URL 参数 name/avatar 并调用 updateUserProfile
- ProfilePage 头像卡片显示真实头像(有 avatar_url 时)
- ProfilePage 增加 基本信息-姓名 编辑字段
2026-05-27 20:24:56 +08:00

515 lines
18 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, 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>
);
}