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