feat: 初始化孕育无忧全栈项目基础代码结构
完成项目初始搭建,包含后端FastAPI+MongoDB服务、前端React+Vite项目的完整基础结构,添加各类配置文件与依赖清单,涵盖核心业务初始框架。
This commit is contained in:
462
frontend/src/app/pages/NotificationPage.tsx
Normal file
462
frontend/src/app/pages/NotificationPage.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
Reference in New Issue
Block a user