Files
app_03_pregnancy/frontend/src/app/context/UserContext.tsx

294 lines
9.2 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 { createContext, useContext, useState, useCallback, useEffect, type ReactNode } from "react";
import {
getToken,
setToken as saveToken,
clearToken,
getUserProfile,
updateUserProfile,
type UserProfileDTO,
} from "../services/api";
export interface UserProfile {
name: string;
age: number | null;
phone: string;
email: string;
address: string;
avatar_url: string | null;
lastPeriodDate: string; // YYYY-MM-DD
dueDate: string;
currentWeek: number;
currentDay: number;
isFirstPregnancy: boolean;
height: number | null;
prePregnancyWeight: number | null;
currentWeight: number | null;
bloodType: string;
allergies: string[];
medicalHistory: string[];
emergencyContact: {
name: string;
relation: string;
phone: string;
};
isOnboarded: boolean;
}
const DEFAULT_PROFILE: UserProfile = {
name: "",
age: null,
phone: "",
email: "",
address: "",
avatar_url: null,
lastPeriodDate: "",
dueDate: "",
currentWeek: 0,
currentDay: 0,
isFirstPregnancy: true,
height: null,
prePregnancyWeight: null,
currentWeight: null,
bloodType: "",
allergies: [],
medicalHistory: [],
emergencyContact: { name: "", relation: "", phone: "" },
isOnboarded: false,
};
const STORAGE_KEY = "pregnancy_user_profile";
const USER_BASIC_KEY = "user_basic";
export interface BasicUserInfo {
id: string;
openid: string;
phone: string;
}
function loadProfile(): UserProfile {
try {
const raw = localStorage.getItem(STORAGE_KEY);
if (raw) {
const saved = JSON.parse(raw) as UserProfile;
if (saved.lastPeriodDate) {
const { weeks, days } = calcPregnancyWeek(saved.lastPeriodDate);
saved.currentWeek = weeks;
saved.currentDay = days;
}
return saved;
}
} catch {}
return DEFAULT_PROFILE;
}
function saveProfile(profile: UserProfile) {
localStorage.setItem(STORAGE_KEY, JSON.stringify(profile));
}
export function calcPregnancyWeek(lastPeriodDate: string) {
const lmp = new Date(lastPeriodDate);
const now = new Date();
const diffDays = Math.floor((now.getTime() - lmp.getTime()) / (24 * 60 * 60 * 1000));
return { weeks: Math.max(0, Math.floor(diffDays / 7)), days: Math.max(0, diffDays % 7) };
}
export function calcDueDate(lastPeriodDate: string): string {
const lmp = new Date(lastPeriodDate);
const due = new Date(lmp.getTime() + 280 * 24 * 60 * 60 * 1000);
return due.toISOString().split("T")[0];
}
export function getPregnancyStage(week: number): string {
if (week <= 12) return "孕早期";
if (week <= 28) return "孕中期";
return "孕晚期";
}
// 基于WHO标准,4-19周为头臀长,20周起为全身长
export function getBabySize(week: number): string {
const sizes: Record<number, string> = {
4: "约0.1厘米,像一粒罂粟种子",
5: "约0.2厘米,像一粒芝麻",
6: "约0.6厘米,像一粒小扁豆",
7: "约1.3厘米,像一颗蓝莓",
8: "约1.6厘米,像一颗覆盆子",
9: "约2.3厘米,像一颗葡萄",
10: "约3.1厘米,像一颗金橘",
11: "约4.1厘米,像一颗无花果",
12: "约5.4厘米,像一颗青柠",
13: "约7.4厘米,像一颗柠檬",
14: "约8.7厘米,像一颗桃子",
16: "约11.6厘米,像一个牛油果",
18: "约14.2厘米,像一个甜椒",
20: "约25.6厘米,像一根香蕉",
22: "约27.8厘米,像一个木瓜",
24: "约30厘米,像一穗玉米",
26: "约35.6厘米,像一颗菜花",
28: "约37.6厘米,像一颗茄子",
30: "约39.9厘米,像一颗卷心菜",
32: "约42.4厘米,像一个椰子",
34: "约45厘米,像一颗哈密瓜",
36: "约47.4厘米,像一颗蜜瓜",
38: "约49.8厘米,像一颗南瓜",
40: "约51.2厘米,像一颗小西瓜",
};
const weeks = Object.keys(sizes).map(Number).sort((a, b) => a - b);
let closest = weeks[0];
for (const w of weeks) {
if (w <= week) closest = w;
else break;
}
return sizes[closest] || `约${week}周`;
}
/** 将后端 DTO 映射为前端 UserProfile */
function mapDtoToProfile(dto: UserProfileDTO): UserProfile {
const lmpDate = dto.last_period_date ? dto.last_period_date.split("T")[0] : "";
let currentWeek = dto.pregnancy_week || 0;
let currentDay = dto.pregnancy_day || 0;
let dueDate = dto.due_date ? dto.due_date.split("T")[0] : "";
// 如果有 LMP 日期,实时计算孕周和预产期
if (lmpDate) {
const calc = calcPregnancyWeek(lmpDate);
currentWeek = calc.weeks;
currentDay = calc.days;
dueDate = calcDueDate(lmpDate);
}
return {
name: dto.name || "",
age: dto.age,
phone: dto.phone || "",
email: dto.email || "",
address: dto.address || "",
avatar_url: dto.avatar_url || null,
lastPeriodDate: lmpDate,
dueDate,
currentWeek,
currentDay,
isFirstPregnancy: true,
height: dto.height,
prePregnancyWeight: dto.pre_pregnancy_weight,
currentWeight: dto.current_weight,
bloodType: dto.blood_type || "",
allergies: dto.allergies || [],
medicalHistory: dto.medical_history || [],
emergencyContact: dto.emergency_contact || { name: "", relation: "", phone: "" },
isOnboarded: dto.is_onboarded,
};
}
interface UserContextValue {
user: UserProfile;
token: string | null;
updateUser: (data: Partial<UserProfile>) => void;
updateUserRemote: (data: Partial<UserProfile>) => Promise<void>;
setToken: (token: string) => void;
setTokenAndUser: (token: string, info: BasicUserInfo) => void;
fetchProfile: () => Promise<UserProfileDTO | null>;
logout: () => void;
}
const UserContext = createContext<UserContextValue | null>(null);
export function UserProvider({ children }: { children: ReactNode }) {
const [user, setUser] = useState<UserProfile>(loadProfile);
const [token, setTokenState] = useState<string | null>(getToken);
const setToken = useCallback((newToken: string) => {
// 清理前一个用户的本地数据,防止用户间数据残留
localStorage.removeItem(STORAGE_KEY);
setUser(DEFAULT_PROFILE);
saveToken(newToken);
setTokenState(newToken);
}, []);
const updateUser = useCallback((data: Partial<UserProfile>) => {
setUser((prev) => {
const updated = { ...prev, ...data };
if (data.lastPeriodDate) {
const { weeks, days } = calcPregnancyWeek(data.lastPeriodDate);
updated.currentWeek = weeks;
updated.currentDay = days;
updated.dueDate = calcDueDate(data.lastPeriodDate);
}
saveProfile(updated);
return updated;
});
}, []);
const updateUserRemote = useCallback(async (data: Partial<UserProfile>) => {
// 乐观更新本地
updateUser(data);
// 同步到后端(camelCase → snake_case)
const payload: Record<string, unknown> = {};
if (data.name !== undefined) payload.name = data.name;
if (data.age !== undefined) payload.age = data.age;
if (data.phone !== undefined) payload.phone = data.phone;
if (data.email !== undefined) payload.email = data.email;
if (data.address !== undefined) payload.address = data.address;
if (data.height !== undefined) payload.height = data.height;
if (data.prePregnancyWeight !== undefined) payload.pre_pregnancy_weight = data.prePregnancyWeight;
if (data.currentWeight !== undefined) payload.current_weight = data.currentWeight;
if (data.bloodType !== undefined) payload.blood_type = data.bloodType;
if (data.allergies !== undefined) payload.allergies = data.allergies;
if (data.medicalHistory !== undefined) payload.medical_history = data.medicalHistory;
if (data.lastPeriodDate !== undefined) payload.last_period_date = data.lastPeriodDate || null;
if (data.emergencyContact !== undefined) payload.emergency_contact = data.emergencyContact;
try {
await updateUserProfile(payload);
} catch (e) {
console.error("同步用户数据到后端失败:", e);
}
}, [updateUser]);
const fetchProfile = useCallback(async (): Promise<UserProfileDTO | null> => {
try {
const dto = await getUserProfile();
const profile = mapDtoToProfile(dto);
setUser(profile);
saveProfile(profile);
return dto;
} catch {
// Token 失效会被 api.ts 自动处理(跳转登录页)
return null;
}
}, []);
const setTokenAndUser = useCallback((newToken: string, info: BasicUserInfo) => {
// 清理前一个用户的本地数据
localStorage.removeItem(STORAGE_KEY);
setUser(DEFAULT_PROFILE);
saveToken(newToken);
localStorage.setItem(USER_BASIC_KEY, JSON.stringify(info));
setTokenState(newToken);
}, []);
const logout = useCallback(() => {
clearToken();
localStorage.removeItem(STORAGE_KEY);
setTokenState(null);
setUser(DEFAULT_PROFILE);
}, []);
// 启动时如果有 token,自动拉取后端数据
useEffect(() => {
if (token) {
fetchProfile();
}
}, []); // eslint-disable-line react-hooks/exhaustive-deps
return (
<UserContext.Provider value={{ user, token, updateUser, updateUserRemote, setToken, setTokenAndUser, fetchProfile, logout }}>
{children}
</UserContext.Provider>
);
}
export function useUser() {
const ctx = useContext(UserContext);
if (!ctx) throw new Error("useUser must be used within UserProvider");
return ctx;
}