Files
qinqinghuisheng/src/elderly/pages/HomePage.tsx

999 lines
38 KiB
TypeScript
Raw Normal View History

2025-12-13 14:46:15 +08:00
import React, { useState, useEffect } from 'react';
import { Mic, Users, Image } from 'lucide-react';
import { AvatarStage } from '../components/AvatarStage';
import { MedicationCard } from '../components/MedicationCard';
import { MoodBoard } from '../components/MoodBoard';
import { MemoryPlayer } from '../components/MemoryPlayer';
import { MediaPlayer } from '../components/MediaPlayer';
import { EmergencySheet } from '../components/EmergencySheet';
import { ScheduleList } from '../components/ScheduleList';
import { ScheduleReminderToast } from '../components/ScheduleReminderToast';
import { ToastMessage } from '../components/ToastMessage';
import { ConfirmDialog } from '../components/ConfirmDialog';
import { TransparentMediaOverlay } from '../components/TransparentMediaOverlay';
import { LogNotification } from '../components/LogNotification';
import * as scheduleService from '../services/scheduleService';
import * as mediaService from '../services/mediaService';
import * as messageService from '../services/messageService';
import * as alertService from '../services/alertService';
import * as moodService from '../../family/services/moodService';
import { useToastSSE } from '../hooks/useToastSSE';
/**
*
* 9:16 使
*
*/
export const HomePage: React.FC = () => {
const [activeOverlay, setActiveOverlay] = useState<
'none' | 'medication' | 'mood' | 'memory' | 'emergency' | 'schedule' | 'media'
>('none');
const [isAvatarActive, setIsAvatarActive] = useState(false);
const [memoryMode, setMemoryMode] = useState<'pip' | 'fullscreen'>('fullscreen');
const [toastMessage, setToastMessage] = useState<{ type: 'success' | 'info' | 'calling'; message: string } | null>(null);
const [confirmDialog, setConfirmDialog] = useState<{ message: string; onConfirm: () => void } | null>(null);
const [currentMediaIndex, setCurrentMediaIndex] = useState(0);
const [sdkStatus, setSDKStatus] = useState<'loading' | 'ready' | 'error' | 'config-missing'>('loading');
const [wsStatus, setWSStatus] = useState<'disconnected' | 'connecting' | 'connected'>('disconnected');
const [todaySchedules, setTodaySchedules] = useState<scheduleService.Schedule[]>([]);
const [reminderSchedule, setReminderSchedule] = useState<scheduleService.Schedule | null>(null);
const [shownReminders, setShownReminders] = useState<Set<number>>(new Set());
const [postponedReminders, setPostponedReminders] = useState<Map<number, Date>>(new Map()); // 记录推迟的日程和推迟到的时间
const [recommendedMedia, setRecommendedMedia] = useState<mediaService.RecommendedMedia[]>([]);
const [loadingMedia, setLoadingMedia] = useState(false);
const [playedMessages, setPlayedMessages] = useState<Set<number>>(new Set()); // 记录已播报的留言ID
const [mediaOverlay, setMediaOverlay] = useState<{
filename: string;
type: 'photo' | 'video';
text?: string;
duration?: number;
} | null>(null); // 透明窗口媒体展示状态
const [isMicrophoneEnabled, setIsMicrophoneEnabled] = useState<boolean>(true); // 麦克风状态
const [currentMood, setCurrentMood] = useState<moodService.MoodType | null>(null); // 当前情绪
const [logMessage, setLogMessage] = useState<string | null>(null); // WebSocket log消息
const familyId = 'family_001'; // 实际使用时从用户上下文获取
const elderlyId = 1; // 老人用户ID实际使用时从用户上下文获取
// 轮询检查联系家人的alerts与家属端相同的方案
useEffect(() => {
let lastAlertId = 0; // 记录上次处理的alert ID
let isInitialized = false; // 标记是否已初始化
const checkContactFamilyAlerts = async () => {
try {
const response = await fetch(
`http://localhost:8000/api/family/alerts?family_id=${familyId}&handled=false&alert_type=contact_family&limit=1`
);
if (!response.ok) {
console.error('[HomePage] 查询alerts失败:', response.status);
return;
}
const data = await response.json();
const alerts = data.alerts || [];
if (alerts.length > 0) {
const alert = alerts[0];
// 首次初始化只记录ID不显示Toast避免刷新页面时重复显示旧alert
if (!isInitialized) {
lastAlertId = alert.id;
isInitialized = true;
console.log('[HomePage] 初始化lastAlertId:', lastAlertId);
return;
}
// 只处理新的alert避免重复显示
if (alert.id > lastAlertId) {
lastAlertId = alert.id;
console.log('[HomePage] ✓ 收到联系家人alert:', alert);
// 显示Toast
const message = alert.metadata?.is_emergency
? "正在紧急通知家人..."
: "正在通知家人...";
setToastMessage({
type: 'calling',
message: message
});
console.log('[HomePage] ✓ Toast已显示:', message);
// 10秒后自动关闭
setTimeout(() => {
console.log('[HomePage] 关闭Toast');
setToastMessage(null);
}, 10000);
}
} else {
// 没有未处理的alert时标记为已初始化
if (!isInitialized) {
isInitialized = true;
console.log('[HomePage] 初始化完成当前无未处理alert');
}
}
} catch (error) {
console.error('[HomePage] 检查alerts失败:', error);
}
};
// 立即执行一次
checkContactFamilyAlerts();
// 每2秒检查一次新alerts
const interval = setInterval(checkContactFamilyAlerts, 2000);
return () => clearInterval(interval);
}, [familyId]);
// 加载今日日程
useEffect(() => {
loadTodaySchedules();
// 每分钟刷新一次
const interval = setInterval(loadTodaySchedules, 60000);
return () => clearInterval(interval);
}, []);
// 监听日程状态变化,自动关闭已完成/已忽略的弹窗
useEffect(() => {
if (reminderSchedule && reminderSchedule.id) {
// 在最新的日程列表中查找当前弹窗对应的日程
const updatedSchedule = todaySchedules.find(s => s.id === reminderSchedule.id);
// 如果日程状态不是pending关闭弹窗
if (updatedSchedule && updatedSchedule.status !== 'pending') {
console.log(`日程 ${reminderSchedule.title} 状态已变更为 ${updatedSchedule.status},关闭弹窗`);
setReminderSchedule(null);
}
}
}, [todaySchedules, reminderSchedule]);
// 加载当前情绪
const loadCurrentMood = async () => {
try {
const response = await moodService.getFamilyMoods(familyId, { limit: 1 });
if (response.records && response.records.length > 0) {
setCurrentMood(response.records[0].mood_type);
}
} catch (error) {
console.error('加载当前情绪失败:', error);
}
};
// 初始化加载当前情绪
useEffect(() => {
loadCurrentMood();
}, []);
// 加载推荐媒体
const loadRecommendedMedia = async () => {
try {
setLoadingMedia(true);
const media = await mediaService.getRecommendedMedia(familyId, elderlyId);
setRecommendedMedia(media);
console.log('加载到推荐媒体:', media.length, '个');
} catch (error) {
console.error('加载推荐媒体失败:', error);
} finally {
setLoadingMedia(false);
}
};
// 检测日程到达时间
useEffect(() => {
const checkScheduleTime = () => {
const now = new Date();
// 遍历今日日程,查找需要提醒的
todaySchedules.forEach((schedule) => {
if (!schedule.id) return;
// 只处理待执行状态的日程
if (schedule.status !== 'pending') return;
// 已经提醒过的跳过
if (shownReminders.has(schedule.id)) return;
// 检查是否被推迟,如果被推迟且未到推迟时间,则跳过
const postponedTime = postponedReminders.get(schedule.id);
if (postponedTime && now < postponedTime) {
console.log(`日程 ${schedule.title} 已推迟到 ${postponedTime.toLocaleTimeString()},暂不提醒`);
return;
}
// 如果已过推迟时间,清除推迟记录
if (postponedTime && now >= postponedTime) {
console.log(`日程 ${schedule.title} 推迟时间已到,现在提醒`);
setPostponedReminders(prev => {
const newMap = new Map(prev);
newMap.delete(schedule.id!);
return newMap;
});
}
const scheduleTime = new Date(schedule.schedule_time);
const diffMinutes = (scheduleTime.getTime() - now.getTime()) / (1000 * 60);
// 到达时间(允许 1 分钟误差)
if (diffMinutes <= 1 && diffMinutes >= -1) {
console.log('日程到达时间,显示提醒:', schedule.title);
// 构建提醒内容
const timeStr = scheduleService.formatTime(schedule.schedule_time);
const typeLabel = scheduleService.getScheduleTypeLabel(schedule.schedule_type || 'other');
let reminderText = `${timeStr}${typeLabel}提醒:${schedule.title}`;
// 如果有描述,添加描述
if (schedule.description) {
reminderText += `${schedule.description}`;
}
// 添加操作询问
reminderText += `。请问您要标记完成、推迟执行,还是忽略行程?`;
// 推送播报内容到数字人
sendToAvatar(reminderText);
// 显示弹窗提醒
setReminderSchedule(schedule);
setShownReminders(prev => new Set(prev).add(schedule.id!));
}
});
};
// 每 10 秒检查一次
const interval = setInterval(checkScheduleTime, 10000);
checkScheduleTime(); // 立即执行一次
return () => clearInterval(interval);
}, [todaySchedules, shownReminders, postponedReminders]);
const loadTodaySchedules = async () => {
try {
const now = new Date().toLocaleTimeString('zh-CN');
console.log(`[${now}] 自动检查日程更新...`);
const data = await scheduleService.getTodaySchedules(familyId);
setTodaySchedules(data);
console.log(`[${now}] 加载到 ${data.length} 条日程`);
} catch (error) {
console.error('加载今日日程失败:', error);
}
};
// 检查并播报待播放的留言
const checkAndPlayMessages = async () => {
try {
const now = new Date().toLocaleTimeString('zh-CN');
console.log(`[${now}] 检查待播放留言...`);
const pendingMessages = await messageService.getPendingMessages(familyId);
if (pendingMessages.length > 0) {
console.log(`发现 ${pendingMessages.length} 条待播放留言`);
for (const message of pendingMessages) {
// 检查是否已经播报过(避免重复播报)
if (!playedMessages.has(message.id)) {
console.log(`播报留言 ID: ${message.id} - 来自${message.sender_relation}${message.sender_name}`);
// 推送到数字人播报
await messageService.playMessageOnAvatar(message);
// 显示Toast字幕提示30秒
const toastText = `来自${message.sender_relation}${message.sender_name}的留言:${message.content}`;
setToastMessage({ type: 'info', message: toastText });
// 30秒后自动关闭Toast
setTimeout(() => {
setToastMessage(null);
}, 30000);
// 标记为已播放
await messageService.markAsPlayed(message.id);
// 记录已播报
setPlayedMessages((prev) => new Set(prev).add(message.id));
console.log(`留言 ID: ${message.id} 播报完成`);
}
}
}
} catch (error) {
console.error('检查并播报留言失败:', error);
}
};
// 定时检查待播放留言(每分钟检查一次)
useEffect(() => {
checkAndPlayMessages(); // 立即执行一次
const interval = setInterval(checkAndPlayMessages, 60000); // 每分钟检查
return () => clearInterval(interval);
}, [playedMessages]);
// 轮询媒体展示事件每5秒检查一次
const pollMediaEvents = async () => {
try {
const response = await fetch(
`http://localhost:8000/api/elderly/poll-media-events?family_id=${familyId}`
);
if (!response.ok) {
throw new Error(`轮询媒体事件失败: ${response.statusText}`);
}
const data = await response.json();
if (data.event && data.event.metadata) {
const metadata = data.event.metadata;
console.log('收到媒体展示事件:', metadata);
// 推送播报内容到数字人(与日程模块相同的逻辑)
if (metadata.avatar_text) {
console.log('准备调用 sendToAvatar文本内容:', metadata.avatar_text);
// 确保麦克风已开启(媒体展示时需要数字人播报)
await ensureMicrophoneEnabled();
await sendToAvatar(metadata.avatar_text);
console.log('sendToAvatar 调用完成');
} else {
console.warn('没有 avatar_text跳过数字人播报');
}
// 设置媒体展示状态,触发透明窗口弹出
setMediaOverlay({
filename: metadata.media_filename,
type: metadata.media_type || 'photo',
text: metadata.avatar_text,
duration: metadata.duration || 30,
});
}
} catch (error) {
console.error('轮询媒体事件错误:', error);
}
};
// 定时轮询媒体展示事件每5秒检查一次
useEffect(() => {
pollMediaEvents(); // 立即执行一次
const interval = setInterval(pollMediaEvents, 5000); // 每5秒检查
return () => clearInterval(interval);
}, []);
// 确保麦克风已开启
const ensureMicrophoneEnabled = async () => {
try {
console.log('[ensureMicrophoneEnabled] 确保麦克风已开启...');
const response = await fetch('http://127.0.0.1:5000/api/toggle-microphone', {
method: 'POST',
headers: {
'Content-Type': 'application/json',
},
body: JSON.stringify({ enabled: true }),
});
if (!response.ok) {
throw new Error(`开启麦克风失败: ${response.statusText}`);
}
const result = await response.json();
console.log('[ensureMicrophoneEnabled] 麦克风状态:', result);
} catch (error) {
console.error('[ensureMicrophoneEnabled] 开启麦克风错误:', error);
}
};
// 向数字人推送播报内容
const sendToAvatar = async (text: string) => {
try {
console.log('[sendToAvatar] 开始推送播报内容:', text);
console.log('[sendToAvatar] 请求URL: http://127.0.0.1:5000/transparent-pass');
const requestBody = {
user: 'User',
text: text,
};
console.log('[sendToAvatar] 请求体:', requestBody);
const response = await fetch('http://127.0.0.1:5000/transparent-pass', {
method: 'POST',
headers: {
'Content-Type': 'application/json',
},
body: JSON.stringify(requestBody),
});
console.log('[sendToAvatar] 响应状态:', response.status, response.statusText);
if (!response.ok) {
const errorText = await response.text();
console.error('[sendToAvatar] 响应错误内容:', errorText);
throw new Error(`推送播报失败: ${response.status} ${response.statusText}`);
}
const result = await response.json();
console.log('[sendToAvatar] 播报内容推送成功,响应:', result);
} catch (error) {
console.error('[sendToAvatar] 推送播报内容错误:', error);
// 不抛出错误,让流程继续(即使播报失败,也要显示媒体)
}
};
// 模拟媒体库数据
const mediaLibrary = [
{ id: '1', url: '/placeholder-photo.jpg', type: 'photo' as const, caption: '小米 2018 秋游' },
{ id: '2', url: '/placeholder-photo-2.jpg', type: 'photo' as const, caption: '2019 春节团聚' },
{ id: '3', url: '/placeholder-photo-3.jpg', type: 'photo' as const, caption: '奶奶80岁生日' },
{ id: '4', url: '/placeholder-photo-4.jpg', type: 'photo' as const, caption: '家庭野餐' },
{ id: '5', url: '/placeholder-photo-5.jpg', type: 'photo' as const, caption: '小孙子周岁' },
];
// 获取当前时间和日期信息
const now = new Date();
const currentTime = now.toLocaleTimeString('zh-CN', {
hour: '2-digit',
minute: '2-digit',
});
const currentDate = now.toLocaleDateString('zh-CN', {
month: 'long',
day: 'numeric',
});
const currentDay = now.toLocaleDateString('zh-CN', {
weekday: 'long',
});
// 模拟天气信息实际使用时应从天气API获取
const weather = '晴 22°C';
// 切换麦克风开关
const handleMicClick = async () => {
try {
console.log('[handleMicClick] 切换麦克风状态,当前状态:', isMicrophoneEnabled);
// 先显示视觉反馈
setIsAvatarActive(true);
setTimeout(() => setIsAvatarActive(false), 1000);
// 调用麦克风切换API不传参数则自动切换
const response = await fetch('http://127.0.0.1:5000/api/toggle-microphone', {
method: 'POST',
headers: {
'Content-Type': 'application/json',
},
body: JSON.stringify({}), // 不传参数,自动切换状态
});
if (!response.ok) {
throw new Error(`切换麦克风失败: ${response.statusText}`);
}
const result = await response.json();
console.log('[handleMicClick] 麦克风切换结果:', result);
// 更新UI状态
setIsMicrophoneEnabled(result.enabled);
} catch (error) {
console.error('[handleMicClick] 切换麦克风错误:', error);
}
};
const handleFamilyClick = async () => {
setToastMessage({ type: 'calling', message: '正在呼叫家人...' });
// 推送普通消息到家属端
try {
await alertService.sendContactFamilyAlert(familyId);
console.log('已通知家人');
} catch (error) {
console.error('通知家人失败:', error);
}
};
const handleEmergencyClick = () => {
setActiveOverlay('emergency');
};
const handlePhotosClick = async () => {
// 加载推荐媒体
await loadRecommendedMedia();
setActiveOverlay('media');
};
const handleNextMedia = () => {
if (currentMediaIndex < mediaLibrary.length - 1) {
setCurrentMediaIndex(currentMediaIndex + 1);
}
};
const handlePreviousMedia = () => {
if (currentMediaIndex > 0) {
setCurrentMediaIndex(currentMediaIndex - 1);
}
};
const handleSelectMedia = (index: number) => {
setCurrentMediaIndex(index);
};
// 获取临近日程前后30分钟内
const getNextSchedule = () => {
if (todaySchedules.length === 0) return null;
const now = new Date();
const sorted = scheduleService.sortSchedulesByTime(todaySchedules);
// 查找在时间窗口内的日程前30分钟到后30分钟
for (const schedule of sorted) {
const scheduleTime = new Date(schedule.schedule_time);
const diffMinutes = (scheduleTime.getTime() - now.getTime()) / (1000 * 60);
// 如果在前后30分钟范围内显示这个日程
if (diffMinutes >= -30 && diffMinutes <= 30) {
return schedule;
}
// 如果日程还在30分钟之后也显示即将到来
if (diffMinutes > 30) {
return schedule;
}
}
// 如果所有日程都已过期超过30分钟不显示任何日程
return null;
};
// 获取临近的药物提醒前后30分钟内
const getNearbyMedicationReminder = () => {
if (todaySchedules.length === 0) return null;
const now = new Date();
// 查找前后30分钟内的药物提醒
for (const schedule of todaySchedules) {
if (schedule.schedule_type !== 'medication') continue;
const scheduleTime = new Date(schedule.schedule_time);
const diffMinutes = (scheduleTime.getTime() - now.getTime()) / (1000 * 60);
// 如果在前后30分钟范围内返回这个药物提醒
if (diffMinutes >= -30 && diffMinutes <= 30) {
return schedule;
}
}
return null;
};
const nextSchedule = getNextSchedule();
const nearbyMedication = getNearbyMedicationReminder();
return (
<div className="h-screen w-full relative elderly-mode overflow-hidden">
{/* 主内容区域 - 满屏显示 */}
<div className="relative w-full h-full bg-gray-50">
{/* 数字人画面 - 全屏背景 */}
<div className="absolute inset-0 z-0">
<AvatarStage
isActive={isAvatarActive}
onSDKStatusChange={setSDKStatus}
onWSStatusChange={setWSStatus}
onLogMessage={setLogMessage}
/>
</div>
{/* PIP 模式的媒体播放器 */}
{activeOverlay === 'memory' && memoryMode === 'pip' && (
<MemoryPlayer
mediaType="photo"
mode="pip"
mediaList={mediaLibrary}
currentIndex={currentMediaIndex}
onLike={() => console.log('Liked')}
onDislike={() => console.log('Disliked')}
onClose={() => setActiveOverlay('none')}
onToggleMode={() => setMemoryMode('fullscreen')}
onNext={handleNextMedia}
onPrevious={handlePreviousMedia}
onSelectMedia={handleSelectMedia}
/>
)}
{/* 顶部状态栏 - 悬浮层 */}
<div className="absolute top-0 left-0 right-0 z-10 bg-gradient-to-b from-black/40 to-transparent px-4 py-3">
<div className="flex items-center justify-between text-white">
{/* 左侧区域:状态指示器 + 时间日期 */}
<div className="flex items-center gap-4">
{/* 连接状态指示器 - 竖排两个绿点 */}
<div className="flex flex-col gap-2">
<span
className={`w-3 h-3 rounded-full animate-pulse ${
sdkStatus === 'ready' ? 'bg-green-500' : sdkStatus === 'loading' ? 'bg-yellow-500' : 'bg-red-500'
}`}
title={sdkStatus === 'ready' ? 'SDK就绪' : sdkStatus === 'loading' ? 'SDK初始化中' : 'SDK错误'}
/>
<span
className={`w-3 h-3 rounded-full animate-pulse ${
wsStatus === 'connected' ? 'bg-green-500' : wsStatus === 'connecting' ? 'bg-yellow-500' : 'bg-gray-500'
}`}
title={wsStatus === 'connected' ? 'WebSocket已连接' : wsStatus === 'connecting' ? 'WebSocket连接中' : 'WebSocket未连接'}
/>
</div>
{/* 时间日期信息 */}
<div className="flex flex-col gap-1">
<div className="flex items-baseline gap-2">
<span className="text-2xl font-bold drop-shadow-lg">
{currentTime}
</span>
<span className="text-base drop-shadow-md">
{currentDate}
</span>
</div>
<div className="flex items-center gap-2 text-sm drop-shadow-md">
<span>{currentDay}</span>
<span className="text-yellow-300"> {weather}</span>
</div>
</div>
</div>
{/* 中间Logo区域 */}
<div className="absolute left-1/2 top-1/2 -translate-x-1/2 -translate-y-1/2 text-center">
<div className="flex items-baseline gap-2 justify-center mb-1">
<h1 className="text-2xl font-bold drop-shadow-lg">KinEcho</h1>
<span className="text-sm drop-shadow-md opacity-90"></span>
</div>
<p className="text-xs drop-shadow-md opacity-80 italic whitespace-nowrap">
Bring family moments to life. /
</p>
</div>
{nextSchedule && (
<button
onClick={() => setActiveOverlay('schedule')}
className="text-xl font-bold bg-white/20 backdrop-blur-sm px-4 py-2 rounded-full hover:bg-white/30 active:scale-95 transition-all"
>
{scheduleService.getScheduleTypeIcon(nextSchedule.schedule_type || 'other')}{' '}
{scheduleService.formatTime(nextSchedule.schedule_time)}{' '}
{nextSchedule.title}
</button>
)}
</div>
</div>
{/* 左侧按钮组 - 功能按钮垂直排列 */}
<div className="absolute left-4 flex flex-col gap-3 z-40" style={{ bottom: '38.2%', transform: 'translateY(50%)' }}>
{/* 麦克风按钮容器 - 用于承载log通知 */}
<div className="relative">
<button
onClick={handleMicClick}
className={`w-16 h-16 flex items-center justify-center ${
isMicrophoneEnabled
? 'bg-blue-500 hover:bg-blue-600'
: 'bg-gray-500 hover:bg-gray-600'
} text-white rounded-full shadow-2xl hover:scale-110 active:scale-95 transition-all`}
aria-label={isMicrophoneEnabled ? '点击关闭麦克风' : '点击开启麦克风'}
>
<Mic size={32} strokeWidth={2.5} />
</button>
{/* Log通知 - 从麦克风按钮向右延伸 */}
{logMessage && (
<LogNotification
message={logMessage}
onHide={() => setLogMessage(null)}
/>
)}
</div>
<button
onClick={handleFamilyClick}
className="w-16 h-16 flex items-center justify-center bg-green-500 hover:bg-green-600 text-white rounded-full shadow-2xl hover:scale-110 active:scale-95 transition-all"
aria-label="联系家人"
>
<Users size={32} strokeWidth={2.5} />
</button>
<button
onClick={handlePhotosClick}
className="w-16 h-16 flex items-center justify-center bg-purple-500 hover:bg-purple-600 text-white rounded-full shadow-2xl hover:scale-110 active:scale-95 transition-all"
aria-label="查看照片和视频"
>
<Image size={32} strokeWidth={2.5} />
</button>
</div>
{/* 右下角 - 紧急求助按钮 */}
<div className="absolute right-4 bottom-4 z-40">
<button
onClick={handleEmergencyClick}
className="w-20 h-20 flex items-center justify-center bg-red-500 hover:bg-red-600 text-white rounded-full shadow-2xl hover:scale-110 active:scale-95 transition-all animate-pulse"
aria-label="我不舒服,需要帮助"
>
<span className="text-4xl">🆘</span>
</button>
</div>
{/* 叠加层 - 用药提醒 */}
{activeOverlay === 'medication' && (
<MedicationCard
medicationName="氯沙坦"
dosage="50mg"
timing="早餐后"
graceMinutes={30}
onTaken={() => {
setActiveOverlay('none');
setToastMessage({ type: 'success', message: '已记录服药' });
}}
onSnooze={(mins) => {
setActiveOverlay('none');
setToastMessage({ type: 'info', message: `将在 ${mins} 分钟后再次提醒` });
}}
onSkip={() => {
setActiveOverlay('none');
setConfirmDialog({
message: '跳过服药可能影响健康,确定吗?',
onConfirm: () => {
setConfirmDialog(null);
setToastMessage({ type: 'info', message: '已记录跳过' });
}
});
}}
onInfo={() => {
setToastMessage({ type: 'info', message: '氯沙坦用于降血压,请随餐服用,避免空腹。' });
}}
/>
)}
{/* 叠加层 - 心情选择 */}
{activeOverlay === 'mood' && (
<MoodBoard
familyId={familyId}
elderlyId={elderlyId}
onMoodSelect={(mood) => {
console.log('Selected mood:', mood);
// 更新当前情绪显示
setCurrentMood(mood as moodService.MoodType);
// 根据心情触发不同的回忆内容
setActiveOverlay('memory');
}}
onClose={() => setActiveOverlay('none')}
/>
)}
{/* 叠加层 - 媒体播放(全屏) */}
{activeOverlay === 'memory' && memoryMode === 'fullscreen' && (
<MemoryPlayer
mediaType="photo"
mode="fullscreen"
mediaList={mediaLibrary}
currentIndex={currentMediaIndex}
onLike={() => console.log('Liked')}
onDislike={() => console.log('Disliked')}
onClose={() => setActiveOverlay('none')}
onToggleMode={() => setMemoryMode('pip')}
onNext={handleNextMedia}
onPrevious={handlePreviousMedia}
onSelectMedia={handleSelectMedia}
/>
)}
{/* 叠加层 - 紧急求助 */}
{activeOverlay === 'emergency' && (
<EmergencySheet
onContactFamily={async () => {
setActiveOverlay('none');
setToastMessage({ type: 'calling', message: '正在呼叫家人...' });
// 推送SOS紧急消息到家属端
try {
await alertService.sendSOSAlert(familyId);
console.log('已发送SOS紧急通知给家人');
} catch (error) {
console.error('发送SOS紧急通知失败:', error);
}
}}
onContactEmergency={async () => {
setActiveOverlay('none');
setToastMessage({ type: 'info', message: '暂未对接应急中心,已通知家人' });
// 也推送SOS紧急消息到家属端
try {
await alertService.sendSOSAlert(familyId);
console.log('已发送SOS紧急通知给家人');
} catch (error) {
console.error('发送SOS紧急通知失败:', error);
}
}}
onClose={() => setActiveOverlay('none')}
/>
)}
{/* 叠加层 - 智能媒体播放器 */}
{activeOverlay === 'media' && recommendedMedia.length > 0 && (
<MediaPlayer
familyId={familyId}
elderlyId={elderlyId}
onClose={() => setActiveOverlay('none')}
/>
)}
{/* 叠加层 - 日程列表 */}
{activeOverlay === 'schedule' && (
<ScheduleList
schedules={todaySchedules}
onClose={() => setActiveOverlay('none')}
/>
)}
{/* Toast 消息提示 */}
{toastMessage && (
<ToastMessage
type={toastMessage.type}
message={toastMessage.message}
onClose={() => setToastMessage(null)}
/>
)}
{/* 确认对话框 */}
{confirmDialog && (
<ConfirmDialog
message={confirmDialog.message}
onConfirm={confirmDialog.onConfirm}
onCancel={() => setConfirmDialog(null)}
/>
)}
{/* 透明窗口媒体展示 */}
{mediaOverlay && (
<TransparentMediaOverlay
mediaFilename={mediaOverlay.filename}
mediaType={mediaOverlay.type}
avatarText={mediaOverlay.text}
duration={mediaOverlay.duration}
onClose={() => setMediaOverlay(null)}
/>
)}
{/* 日程提醒 Toast */}
{reminderSchedule && (
<ScheduleReminderToast
schedule={reminderSchedule}
onComplete={async () => {
try {
if (reminderSchedule.id) {
// 更新日程状态为已完成
await scheduleService.updateScheduleStatus(reminderSchedule.id, 'completed');
}
setReminderSchedule(null);
// 重新加载日程列表
loadTodaySchedules();
} catch (error) {
console.error('标记完成失败:', error);
}
}}
onSkip={async () => {
try {
if (reminderSchedule.id) {
// 更新日程状态为已忽略
await scheduleService.updateScheduleStatus(reminderSchedule.id, 'skipped');
}
setReminderSchedule(null);
// 重新加载日程列表
loadTodaySchedules();
} catch (error) {
console.error('忽略行程失败:', error);
}
}}
onDismiss={async () => {
if (!reminderSchedule) return;
console.log('推迟执行日程:', reminderSchedule);
try {
// 计算10分钟后的时间
const postponedTime = new Date();
postponedTime.setMinutes(postponedTime.getMinutes() + 10);
// 如果是每日重复的日程,只推迟当前实例
if (reminderSchedule.repeat_type === 'daily') {
// 记录推迟时间到 postponedReminders Map
setPostponedReminders(prev => {
const newMap = new Map(prev);
newMap.set(reminderSchedule.id!, postponedTime);
return newMap;
});
// 从已显示列表中移除,允许再次提醒
setShownReminders(prev => {
const newSet = new Set(prev);
if (reminderSchedule.id) newSet.delete(reminderSchedule.id);
return newSet;
});
console.log(`每日重复日程已推迟到 ${postponedTime.toLocaleTimeString()},原日程保持不变`);
setReminderSchedule(null);
} else {
// 如果是一次性日程,更新数据库中的 schedule_time
const response = await fetch(
`http://localhost:8000/api/family/schedules/${reminderSchedule.id}`,
{
method: 'PUT',
headers: {
'Content-Type': 'application/json',
},
body: JSON.stringify({
family_id: reminderSchedule.family_id,
title: reminderSchedule.title,
description: reminderSchedule.description,
schedule_type: reminderSchedule.schedule_type,
schedule_time: postponedTime.toISOString(),
repeat_type: reminderSchedule.repeat_type,
repeat_days: reminderSchedule.repeat_days,
auto_remind: reminderSchedule.auto_remind,
status: 'pending', // 推迟后重置为pending状态
}),
}
);
if (response.ok) {
console.log('一次性日程时间已更新为10分钟后:', postponedTime.toISOString());
setReminderSchedule(null);
// 从已显示列表中移除允许10分钟后再次提醒
setShownReminders(prev => {
const newSet = new Set(prev);
if (reminderSchedule.id) newSet.delete(reminderSchedule.id);
return newSet;
});
// 重新加载日程列表
loadTodaySchedules();
} else {
const errorText = await response.text();
console.error('更新失败响应:', errorText);
throw new Error('更新日程时间失败');
}
}
} catch (error) {
console.error('推迟执行失败:', error);
}
}}
onMissed={async () => {
try {
if (reminderSchedule?.id) {
console.log('30分钟无操作自动标记为已错过:', reminderSchedule.title);
// 更新日程状态为已错过
await scheduleService.updateScheduleStatus(reminderSchedule.id, 'missed');
}
setReminderSchedule(null);
// 重新加载日程列表
loadTodaySchedules();
} catch (error) {
console.error('标记已错过失败:', error);
setReminderSchedule(null);
}
}}
onClose={() => {
// 手动关闭提醒(不做任何操作,只是隐藏)
setReminderSchedule(null);
}}
/>
)}
{/* 调试按钮 - 方便演示 */}
<div className="absolute left-4 flex flex-col gap-3 opacity-30 hover:opacity-100 transition-opacity z-40" style={{ top: '50%', transform: 'translateY(-50%)' }}>
{/* 药物提醒按钮 - 只在前后30分钟有药物提醒时显示 */}
{nearbyMedication && (
<button
onClick={() => setActiveOverlay('medication')}
className="w-16 h-16 flex items-center justify-center bg-blue-600 text-white text-3xl rounded-full shadow-2xl backdrop-blur-sm hover:scale-110 active:scale-95 transition-all"
>
💊
</button>
)}
<button
onClick={() => setActiveOverlay('mood')}
className="w-16 h-16 flex items-center justify-center bg-amber-500 text-white text-3xl rounded-full shadow-2xl backdrop-blur-sm hover:scale-110 active:scale-95 transition-all"
>
{currentMood ? moodService.moodEmojiMap[currentMood] : '😊'}
</button>
</div>
</div>
</div>
);
};