Files
app_03_pregnancy/frontend/src/app/pages/NotificationPage.tsx
Karlcarlo 409dabafb5 feat: 初始化孕育无忧全栈项目基础代码结构
完成项目初始搭建,包含后端FastAPI+MongoDB服务、前端React+Vite项目的完整基础结构,添加各类配置文件与依赖清单,涵盖核心业务初始框架。
2026-05-21 00:46:48 +08:00

463 lines
18 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 { 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>
);
}