feat: 初始化孕育无忧全栈项目基础代码结构

完成项目初始搭建,包含后端FastAPI+MongoDB服务、前端React+Vite项目的完整基础结构,添加各类配置文件与依赖清单,涵盖核心业务初始框架。
This commit is contained in:
2026-05-21 00:46:48 +08:00
commit 409dabafb5
142 changed files with 31010 additions and 0 deletions

View File

@@ -0,0 +1,263 @@
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;
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: "",
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";
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 || "",
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;
fetchProfile: () => Promise<void>;
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.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 () => {
try {
const dto = await getUserProfile();
const profile = mapDtoToProfile(dto);
setUser(profile);
saveProfile(profile);
} catch {
// Token 失效会被 api.ts 自动处理(跳转登录页)
}
}, []);
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, 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;
}