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,462 @@
import { useState } from "react";
import { useNavigate } from "react-router";
import { motion, AnimatePresence } from "motion/react";
import {
ChevronLeft,
Bell,
Clock,
CheckCircle2,
ChevronRight,
Stethoscope,
Baby,
CircleDot,
AlertCircle,
Calendar,
} from "lucide-react";
import { Card } from "../components/ui/card";
import { Badge } from "../components/ui/badge";
import { useUser, calcPregnancyWeek } from "../context/UserContext";
import { checkupSchedule } from "../data/checkupSchedule";
interface Notification {
id: string;
type: "checkup_reminder" | "preparation" | "weekly_tip" | "milestone";
title: string;
content: string;
time: string;
read: boolean;
tags?: string[];
link?: string;
checkupItems?: string[];
checkupPreparations?: string[];
}
function generateNotifications(currentWeek: number, lastPeriodDate: string): Notification[] {
const notifications: Notification[] = [];
// 下次产检提醒(来源于产检日历)
const nextCheckup = checkupSchedule.find((c) => c.week > currentWeek);
if (nextCheckup) {
let dateStr = "";
let daysInfo = "";
if (lastPeriodDate) {
const lmp = new Date(lastPeriodDate);
const checkupDate = new Date(lmp.getTime() + nextCheckup.week * 7 * 24 * 60 * 60 * 1000);
dateStr = checkupDate.toISOString().split("T")[0];
const daysUntil = Math.max(0, Math.ceil((checkupDate.getTime() - Date.now()) / (1000 * 60 * 60 * 24)));
daysInfo = `${dateStr} · 距今${daysUntil}天`;
} else {
daysInfo = `还有${nextCheckup.week - currentWeek}周`;
}
notifications.push({
id: "next-checkup",
type: "checkup_reminder",
title: `下次产检:${nextCheckup.title}`,
content: `孕${nextCheckup.week}周 · ${daysInfo}`,
time: "今天",
read: false,
tags: nextCheckup.preparations.filter(
(p) => p.includes("空腹") || p.includes("憋尿") || p.startsWith("需")
),
checkupItems: nextCheckup.items,
checkupPreparations: nextCheckup.preparations,
});
// 产检项目提醒
notifications.push({
id: "checkup-items",
type: "preparation",
title: `${nextCheckup.title}检查项目`,
content: `共${nextCheckup.items.length}项:${nextCheckup.items.slice(0, 3).join("、")}${nextCheckup.items.length > 3 ? "等" : ""}`,
time: "今天",
read: false,
link: "/calendar",
});
// 特殊注意事项
const specialPreps = nextCheckup.preparations.filter(
(p) => p.includes("空腹") || p.includes("憋尿") || p.includes("携带") || p.includes("预约")
);
if (specialPreps.length > 0) {
notifications.push({
id: "prep-reminder",
type: "preparation",
title: "产检准备提醒",
content: specialPreps.join(";"),
time: "今天",
read: false,
tags: specialPreps.filter((p) => p.includes("空腹") || p.includes("憋尿")),
});
}
}
// 孕周知识推送
notifications.push({
id: "weekly-tip",
type: "weekly_tip",
title: `孕${currentWeek}周知识`,
content: `本周宝宝正在快速发育,请注意补充营养,避免剧烈运动,保持充足睡眠。`,
time: "今天",
read: false,
});
// 孕期里程碑
const milestones: Record<number, string> = {
8: "宝宝的心脏已经开始跳动,四肢也开始发育。记得按时服用叶酸哦~",
12: "宝宝已经有了人的模样,可以做NT检查了。进入相对稳定的孕中期!",
16: "宝宝开始有了听觉,可以和宝宝说说话啦~",
20: "宝宝可以感受到外界的光线了,是做大排畸检查的好时机!",
24: "宝宝体重快速增长,记得做糖耐量检查哦~",
28: "进入孕晚期啦!产检频率要增加了,注意数胎动~",
32: "宝宝头部朝下准备入盆,可以开始准备待产包了!",
36: "宝宝随时可能发动,做好迎接宝宝的准备吧~",
};
const milestoneWeek = Object.keys(milestones).map(Number).find((w) => w === currentWeek);
if (milestoneWeek) {
notifications.push({
id: `milestone-${milestoneWeek}`,
type: "milestone",
title: `孕${milestoneWeek}周里程碑`,
content: `恭喜进入孕${milestoneWeek}周!${milestones[milestoneWeek]}`,
time: "昨天",
read: true,
});
}
// 下下次产检预告
const secondCheckup = checkupSchedule.find((c) => nextCheckup && c.week > nextCheckup.week);
if (secondCheckup) {
notifications.push({
id: "future-checkup",
type: "checkup_reminder",
title: `产检预告:${secondCheckup.title}`,
content: `孕${secondCheckup.week}周 · ${secondCheckup.items.slice(0, 2).join("、")}等`,
time: "3天前",
read: true,
link: "/calendar",
});
}
// 上周知识回顾
if (currentWeek > 1) {
notifications.push({
id: "last-week-tip",
type: "weekly_tip",
title: `孕${currentWeek - 1}周回顾`,
content: "上周的营养建议:多摄入富含蛋白质和铁元素的食物,每日补充0.4mg叶酸。",
time: "一周前",
read: true,
});
}
return notifications;
}
export function NotificationPage() {
const navigate = useNavigate();
const { user } = useUser();
const currentWeek = user.currentWeek || 0;
const [notifications, setNotifications] = useState<Notification[]>(() =>
generateNotifications(currentWeek, user.lastPeriodDate)
);
const [filter, setFilter] = useState<"all" | "unread">("all");
const [expandedId, setExpandedId] = useState<string | null>(null);
const unreadCount = notifications.filter((n) => !n.read && n.type !== "preparation").length;
const filteredNotifications =
filter === "unread"
? notifications.filter((n) => !n.read && n.type !== "preparation")
: notifications.filter((n) => n.type !== "preparation");
const markAsRead = (id: string) => {
setNotifications((prev) =>
prev.map((n) => (n.id === id ? { ...n, read: true } : n))
);
};
const markAllRead = () => {
setNotifications((prev) => prev.map((n) => ({ ...n, read: true })));
};
const getIcon = (type: Notification["type"]) => {
switch (type) {
case "checkup_reminder":
return <Stethoscope className="w-5 h-5 text-morandi-500" />;
case "preparation":
return <AlertCircle className="w-5 h-5 text-morandi-rose-400" />;
case "weekly_tip":
return <Baby className="w-5 h-5 text-morandi-400" />;
case "milestone":
return <CircleDot className="w-5 h-5 text-morandi-sage-400" />;
}
};
const getIconBg = (type: Notification["type"]) => {
switch (type) {
case "checkup_reminder":
return "bg-morandi-100";
case "preparation":
return "bg-morandi-rose-100";
case "weekly_tip":
return "bg-morandi-50";
case "milestone":
return "bg-morandi-sage-100";
}
};
const getTypeLabel = (type: Notification["type"]) => {
switch (type) {
case "checkup_reminder":
return "产检提醒";
case "preparation":
return "注意事项";
case "weekly_tip":
return "孕周知识";
case "milestone":
return "孕期里程碑";
}
};
return (
<div className="min-h-dvh bg-morandi-50">
{/* 顶部导航 */}
<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 className="flex items-center gap-2">
<div className="w-10 h-10 bg-gradient-to-br from-morandi-300 to-morandi-400 rounded-full flex items-center justify-center">
<Bell className="w-5 h-5 text-white" />
</div>
<div>
<h1 className="font-semibold text-gray-800">消息中心</h1>
<p className="text-xs text-gray-500">
{unreadCount > 0 ? `${unreadCount}条未读消息` : "暂无未读消息"}
</p>
</div>
</div>
</div>
{unreadCount > 0 && (
<button
onClick={markAllRead}
className="text-xs text-morandi-500 hover:text-morandi-600 font-medium"
>
全部已读
</button>
)}
</div>
</div>
<div className="mx-auto p-4 space-y-5">
{/* 筛选按钮 */}
<motion.div
initial={{ opacity: 0, y: 20 }}
animate={{ opacity: 1, y: 0 }}
>
<div className="flex items-center gap-3">
<h2 className="font-semibold text-gray-800 flex-1">全部消息</h2>
<div className="flex gap-1.5 bg-white rounded-full p-1 shadow-sm">
{(["all", "unread"] as const).map((f) => (
<button
key={f}
onClick={() => setFilter(f)}
className={`px-4 py-1.5 rounded-full text-xs font-medium transition-all ${
filter === f
? "bg-morandi-400 text-white"
: "text-gray-500 hover:bg-morandi-50"
}`}
>
{f === "all" ? "全部" : `未读 (${unreadCount})`}
</button>
))}
</div>
</div>
</motion.div>
{/* 消息列表 */}
<motion.div
initial={{ opacity: 0, y: 20 }}
animate={{ opacity: 1, y: 0 }}
transition={{ delay: 0.05 }}
className="space-y-3"
>
<AnimatePresence mode="popLayout">
{filteredNotifications.length === 0 ? (
<motion.div
initial={{ opacity: 0 }}
animate={{ opacity: 1 }}
className="text-center py-12"
>
<CheckCircle2 className="w-12 h-12 text-morandi-200 mx-auto mb-3" />
<p className="text-gray-400 text-sm">暂无未读消息</p>
</motion.div>
) : (
filteredNotifications.map((notification, index) => (
<motion.div
key={notification.id}
layout
initial={{ opacity: 0, x: -20 }}
animate={{ opacity: 1, x: 0 }}
exit={{ opacity: 0, x: 20 }}
transition={{ delay: index * 0.03 }}
>
<Card
className={`rounded-2xl p-4 shadow-sm border-0 cursor-pointer transition-all hover:shadow-md ${
!notification.read
? "bg-white border-l-4 border-l-morandi-400"
: "bg-white opacity-80"
}`}
onClick={() => {
markAsRead(notification.id);
if (expandedId === notification.id) {
setExpandedId(null);
} else {
setExpandedId(notification.id);
}
}}
>
<div className="flex items-start gap-3">
<div
className={`w-10 h-10 rounded-xl flex items-center justify-center flex-shrink-0 ${getIconBg(
notification.type
)}`}
>
{getIcon(notification.type)}
</div>
<div className="flex-1 min-w-0">
<div className="flex items-center gap-2 mb-1">
<Badge
className={`text-[10px] px-1.5 py-0 ${
notification.type === "checkup_reminder"
? "bg-morandi-100 text-morandi-600"
: notification.type === "preparation"
? "bg-morandi-rose-100 text-morandi-rose-400"
: notification.type === "milestone"
? "bg-morandi-sage-100 text-morandi-sage-500"
: "bg-morandi-50 text-morandi-400"
} hover:bg-current`}
>
{getTypeLabel(notification.type)}
</Badge>
<span className="text-[10px] text-gray-400 ml-auto flex-shrink-0">
{notification.time}
</span>
{!notification.read && (
<span className="w-2 h-2 bg-morandi-rose-400 rounded-full flex-shrink-0" />
)}
</div>
<h4 className="font-medium text-gray-800 text-sm mb-1 truncate">
{notification.title}
</h4>
<p className="text-xs text-gray-500 leading-relaxed line-clamp-2">
{notification.content}
</p>
{/* 特殊标签 */}
{notification.tags && notification.tags.length > 0 && (
<div className="flex gap-1.5 mt-2 flex-wrap">
{notification.tags.map((tag, i) => (
<span
key={i}
className="inline-flex items-center gap-1 px-2 py-0.5 bg-morandi-rose-100 text-morandi-rose-400 rounded-full text-[10px] font-medium"
>
<AlertCircle className="w-2.5 h-2.5" />
{tag}
</span>
))}
</div>
)}
{/* 展开的详情 */}
<AnimatePresence>
{expandedId === notification.id && (
<motion.div
initial={{ height: 0, opacity: 0 }}
animate={{ height: "auto", opacity: 1 }}
exit={{ height: 0, opacity: 0 }}
className="overflow-hidden"
>
<div className="mt-3 pt-3 border-t border-gray-100 space-y-3">
{/* 检查项目 */}
{notification.checkupItems && notification.checkupItems.length > 0 && (
<div>
<h5 className="font-semibold text-gray-700 mb-2 text-xs">
检查项目
</h5>
<div className="grid grid-cols-2 gap-2">
{notification.checkupItems.map((item, i) => (
<div
key={i}
className="text-xs text-gray-600 flex items-center gap-2 bg-gray-50 rounded-lg px-2.5 py-1.5"
>
<div className="w-1 h-1 bg-morandi-400 rounded-full flex-shrink-0"></div>
{item}
</div>
))}
</div>
</div>
)}
{/* 注意事项 */}
{notification.checkupPreparations && notification.checkupPreparations.length > 0 && (
<div className="bg-morandi-rose-50 rounded-xl p-3">
<h5 className="font-semibold text-morandi-700 mb-2 text-xs flex items-center gap-1.5">
<AlertCircle className="w-3.5 h-3.5" />
注意事项
</h5>
<ul className="space-y-1">
{notification.checkupPreparations.map((prep, i) => (
<li
key={i}
className="text-xs text-morandi-600 flex items-start gap-2"
>
<span className="w-1 h-1 bg-morandi-rose-400 rounded-full flex-shrink-0 mt-1.5"></span>
<span>{prep}</span>
</li>
))}
</ul>
</div>
)}
</div>
</motion.div>
)}
</AnimatePresence>
</div>
</div>
</Card>
</motion.div>
))
)}
</AnimatePresence>
</motion.div>
{/* 温馨提示 */}
<motion.div
initial={{ opacity: 0, y: 20 }}
animate={{ opacity: 1, y: 0 }}
transition={{ delay: 0.15 }}
>
<Card className="bg-morandi-50 border-morandi-100 rounded-2xl p-4">
<div className="flex items-start gap-2.5">
<Bell className="w-4 h-4 text-morandi-400 flex-shrink-0 mt-0.5" />
<div>
<h4 className="font-medium text-gray-700 text-sm mb-1">消息说明</h4>
<p className="text-xs text-gray-500 leading-relaxed">
系统会根据您的孕周和产检计划自动推送提醒,包括产检时间、检查项目和注意事项。产检内容与产检日历同步。
</p>
</div>
</div>
</Card>
</motion.div>
<div className="h-4" />
</div>
</div>
);
}