294 lines
9.2 KiB
TypeScript
294 lines
9.2 KiB
TypeScript
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;
|
||
}
|