Files
qinqinghuisheng/src/elderly/pages/HomePage.tsx
2025-12-13 14:46:15 +08:00

999 lines
38 KiB
TypeScript
Raw Blame History

This file contains invisible Unicode characters

This file contains invisible Unicode characters that are indistinguishable to humans but may be processed differently by a computer. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

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 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>
);
};