Add File
This commit is contained in:
415
src/family/pages/AlertsAndCare.tsx
Normal file
415
src/family/pages/AlertsAndCare.tsx
Normal file
@@ -0,0 +1,415 @@
|
|||||||
|
import React, { useState, useEffect } from 'react';
|
||||||
|
import {
|
||||||
|
AlertTriangle,
|
||||||
|
AlertCircle,
|
||||||
|
Clock,
|
||||||
|
Heart,
|
||||||
|
MessageCircle,
|
||||||
|
Send,
|
||||||
|
CheckCircle,
|
||||||
|
Phone,
|
||||||
|
} from 'lucide-react';
|
||||||
|
import * as messageService from '../services/messageService';
|
||||||
|
|
||||||
|
interface Alert {
|
||||||
|
id: string;
|
||||||
|
level: 'low' | 'medium' | 'high';
|
||||||
|
type: 'medication' | 'emotion' | 'inactive' | 'emergency' | 'sos_emergency' | 'contact_family';
|
||||||
|
message: string;
|
||||||
|
timestamp: string;
|
||||||
|
handled: boolean;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 家属端通知与远程关怀界面
|
||||||
|
* 显示通知时间线,支持发送祝福消息
|
||||||
|
*/
|
||||||
|
export const AlertsAndCare: React.FC = () => {
|
||||||
|
const [showMessageComposer, setShowMessageComposer] = useState(false);
|
||||||
|
const [messageText, setMessageText] = useState('');
|
||||||
|
const [deliveryTiming, setDeliveryTiming] = useState('asap');
|
||||||
|
const [alerts, setAlerts] = useState<Alert[]>([]);
|
||||||
|
const [loading, setLoading] = useState(true);
|
||||||
|
const [filterType, setFilterType] = useState<'all' | 'unhandled' | 'high'>('all');
|
||||||
|
const [stats, setStats] = useState<{
|
||||||
|
total: number;
|
||||||
|
unhandled: number;
|
||||||
|
high: number;
|
||||||
|
}>({ total: 0, unhandled: 0, high: 0 });
|
||||||
|
const familyId = 'family_001'; // 实际使用时从用户上下文获取
|
||||||
|
|
||||||
|
// 加载消息/告警数据
|
||||||
|
useEffect(() => {
|
||||||
|
loadAlerts();
|
||||||
|
loadStats();
|
||||||
|
// 每10秒轮询一次新消息
|
||||||
|
const interval = setInterval(() => {
|
||||||
|
loadAlerts();
|
||||||
|
loadStats();
|
||||||
|
}, 10000);
|
||||||
|
return () => clearInterval(interval);
|
||||||
|
}, [filterType]);
|
||||||
|
|
||||||
|
const loadStats = async () => {
|
||||||
|
try {
|
||||||
|
const statsData = await messageService.getAlertStats(familyId);
|
||||||
|
|
||||||
|
// 计算各个筛选项的数量
|
||||||
|
// total 应该是所有消息的总数,而不是今天的消息数
|
||||||
|
const totalFromLevels = Object.values(statsData.level_stats || {}).reduce((sum, count) => sum + count, 0);
|
||||||
|
const total = totalFromLevels;
|
||||||
|
const unhandled = statsData.status_stats?.unhandled || 0;
|
||||||
|
const high = statsData.level_stats?.high || 0;
|
||||||
|
|
||||||
|
setStats({ total, unhandled, high });
|
||||||
|
} catch (error) {
|
||||||
|
console.error('加载统计失败:', error);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const loadAlerts = async () => {
|
||||||
|
try {
|
||||||
|
setLoading(true);
|
||||||
|
|
||||||
|
// 根据筛选类型设置API参数
|
||||||
|
const options: any = {};
|
||||||
|
if (filterType === 'unhandled') {
|
||||||
|
options.handled = false;
|
||||||
|
} else if (filterType === 'high') {
|
||||||
|
options.level = 'high';
|
||||||
|
}
|
||||||
|
|
||||||
|
const { alerts: data } = await messageService.getFamilyAlerts(familyId, options);
|
||||||
|
|
||||||
|
// 转换数据格式
|
||||||
|
const convertedAlerts: Alert[] = data.map((alert) => ({
|
||||||
|
id: alert.id.toString(),
|
||||||
|
level: alert.level,
|
||||||
|
type: alert.alert_type,
|
||||||
|
message: alert.message,
|
||||||
|
timestamp: new Date(alert.created_at).toLocaleString('zh-CN', {
|
||||||
|
year: 'numeric',
|
||||||
|
month: '2-digit',
|
||||||
|
day: '2-digit',
|
||||||
|
hour: '2-digit',
|
||||||
|
minute: '2-digit',
|
||||||
|
}),
|
||||||
|
handled: alert.handled,
|
||||||
|
}));
|
||||||
|
|
||||||
|
setAlerts(convertedAlerts);
|
||||||
|
} catch (error) {
|
||||||
|
console.error('加载消息失败:', error);
|
||||||
|
// 如果API失败,使用模拟数据
|
||||||
|
setAlerts([
|
||||||
|
{
|
||||||
|
id: '1',
|
||||||
|
level: 'high',
|
||||||
|
type: 'emotion',
|
||||||
|
message: '检测到连续负面情绪,已触发安抚流程',
|
||||||
|
timestamp: '2023-11-13 14:30',
|
||||||
|
handled: false,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: '2',
|
||||||
|
level: 'medium',
|
||||||
|
type: 'medication',
|
||||||
|
message: '晚药延迟 15 分钟服用',
|
||||||
|
timestamp: '2023-11-13 20:15',
|
||||||
|
handled: true,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: '3',
|
||||||
|
level: 'low',
|
||||||
|
type: 'inactive',
|
||||||
|
message: '下午时段互动较少(仅 1 次对话)',
|
||||||
|
timestamp: '2023-11-13 17:00',
|
||||||
|
handled: true,
|
||||||
|
},
|
||||||
|
]);
|
||||||
|
} finally {
|
||||||
|
setLoading(false);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const getLevelConfig = (level: Alert['level']) => {
|
||||||
|
switch (level) {
|
||||||
|
case 'high':
|
||||||
|
return {
|
||||||
|
bg: 'bg-red-50',
|
||||||
|
border: 'border-red-400',
|
||||||
|
text: 'text-red-700',
|
||||||
|
icon: AlertTriangle,
|
||||||
|
label: '🚨 紧急',
|
||||||
|
};
|
||||||
|
case 'medium':
|
||||||
|
return {
|
||||||
|
bg: 'bg-orange-50',
|
||||||
|
border: 'border-orange-400',
|
||||||
|
text: 'text-orange-700',
|
||||||
|
icon: Phone,
|
||||||
|
label: '📞 普通',
|
||||||
|
};
|
||||||
|
case 'low':
|
||||||
|
return {
|
||||||
|
bg: 'bg-yellow-50',
|
||||||
|
border: 'border-yellow-400',
|
||||||
|
text: 'text-yellow-700',
|
||||||
|
icon: Clock,
|
||||||
|
label: '💡 提示',
|
||||||
|
};
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const getTypeIcon = (type: Alert['type']) => {
|
||||||
|
switch (type) {
|
||||||
|
case 'sos_emergency':
|
||||||
|
return AlertTriangle;
|
||||||
|
case 'contact_family':
|
||||||
|
return Phone;
|
||||||
|
case 'emotion':
|
||||||
|
return Heart;
|
||||||
|
case 'medication':
|
||||||
|
return Clock;
|
||||||
|
case 'inactive':
|
||||||
|
return MessageCircle;
|
||||||
|
default:
|
||||||
|
return AlertCircle;
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleMarkAsHandled = async (alertId: string) => {
|
||||||
|
try {
|
||||||
|
await messageService.handleAlert(Number(alertId));
|
||||||
|
// 重新加载数据和统计
|
||||||
|
await loadAlerts();
|
||||||
|
await loadStats();
|
||||||
|
} catch (error) {
|
||||||
|
console.error('标记为已处理失败:', error);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleSendMessage = () => {
|
||||||
|
console.log('Send message:', { messageText, deliveryTiming });
|
||||||
|
setShowMessageComposer(false);
|
||||||
|
setMessageText('');
|
||||||
|
};
|
||||||
|
|
||||||
|
// 显示加载状态
|
||||||
|
if (loading && alerts.length === 0) {
|
||||||
|
return (
|
||||||
|
<div className="min-h-screen bg-gray-50 flex items-center justify-center">
|
||||||
|
<div className="text-center">
|
||||||
|
<div className="animate-spin rounded-full h-12 w-12 border-b-2 border-primary-600 mx-auto mb-4"></div>
|
||||||
|
<p className="text-gray-600">正在加载消息...</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="min-h-screen bg-gray-50">
|
||||||
|
{/* 顶部导航 */}
|
||||||
|
<div className="bg-white border-b">
|
||||||
|
<div className="px-4 py-3">
|
||||||
|
<div className="flex items-center justify-between mb-3">
|
||||||
|
<h1 className="text-xl font-bold text-gray-900">通知</h1>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* 筛选器 */}
|
||||||
|
<div className="flex gap-2 overflow-x-auto pb-2 -mb-2 scrollbar-hide">
|
||||||
|
<button
|
||||||
|
onClick={() => setFilterType('all')}
|
||||||
|
className={`px-4 py-2 rounded-full text-sm font-medium whitespace-nowrap transition-colors ${
|
||||||
|
filterType === 'all'
|
||||||
|
? 'bg-primary-100 text-primary-700'
|
||||||
|
: 'bg-gray-100 text-gray-700 hover:bg-gray-200'
|
||||||
|
}`}
|
||||||
|
>
|
||||||
|
全部 ({stats.total})
|
||||||
|
</button>
|
||||||
|
<button
|
||||||
|
onClick={() => setFilterType('unhandled')}
|
||||||
|
className={`px-4 py-2 rounded-full text-sm font-medium whitespace-nowrap transition-colors ${
|
||||||
|
filterType === 'unhandled'
|
||||||
|
? 'bg-primary-100 text-primary-700'
|
||||||
|
: 'bg-gray-100 text-gray-700 hover:bg-gray-200'
|
||||||
|
}`}
|
||||||
|
>
|
||||||
|
未处理 ({stats.unhandled})
|
||||||
|
</button>
|
||||||
|
<button
|
||||||
|
onClick={() => setFilterType('high')}
|
||||||
|
className={`px-4 py-2 rounded-full text-sm font-medium whitespace-nowrap transition-colors ${
|
||||||
|
filterType === 'high'
|
||||||
|
? 'bg-primary-100 text-primary-700'
|
||||||
|
: 'bg-gray-100 text-gray-700 hover:bg-gray-200'
|
||||||
|
}`}
|
||||||
|
>
|
||||||
|
重要 ({stats.high})
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* 主要内容区 - 手机优化 */}
|
||||||
|
<div className="px-4 py-4">
|
||||||
|
{/* 通知列表 */}
|
||||||
|
<div className="space-y-3">
|
||||||
|
{alerts.map((alert) => {
|
||||||
|
const config = getLevelConfig(alert.level);
|
||||||
|
const Icon = config.icon;
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div
|
||||||
|
key={alert.id}
|
||||||
|
className={`p-4 border-l-4 rounded-lg ${config.bg} ${config.border}`}
|
||||||
|
>
|
||||||
|
<div className="flex items-start gap-3">
|
||||||
|
<Icon className={config.text} size={20} />
|
||||||
|
<div className="flex-1 min-w-0">
|
||||||
|
<div className="flex items-center gap-2 mb-1 flex-wrap">
|
||||||
|
<span className={`text-xs font-bold ${config.text} uppercase`}>
|
||||||
|
{config.label}
|
||||||
|
</span>
|
||||||
|
<span className="text-xs text-gray-500">
|
||||||
|
{alert.timestamp}
|
||||||
|
</span>
|
||||||
|
{alert.handled && (
|
||||||
|
<span className="text-xs text-green-600 flex items-center gap-1">
|
||||||
|
<CheckCircle size={14} />
|
||||||
|
已处理
|
||||||
|
</span>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
<p className={`text-sm ${config.text} font-medium mb-3`}>
|
||||||
|
{alert.message}
|
||||||
|
</p>
|
||||||
|
{!alert.handled && (
|
||||||
|
<div className="flex gap-2 flex-wrap">
|
||||||
|
<button
|
||||||
|
onClick={() => handleMarkAsHandled(alert.id)}
|
||||||
|
className="px-3 py-1.5 bg-white border border-gray-300 text-gray-700 rounded-lg text-sm hover:bg-gray-50 transition-colors"
|
||||||
|
>
|
||||||
|
标记已处理
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
})}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* 空状态 */}
|
||||||
|
{alerts.length === 0 && (
|
||||||
|
<div className="text-center py-12">
|
||||||
|
<AlertCircle size={48} className="mx-auto text-gray-400 mb-3" />
|
||||||
|
<p className="text-gray-500">暂无通知</p>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* 消息编辑器弹窗 */}
|
||||||
|
{showMessageComposer && (
|
||||||
|
<div className="fixed inset-0 bg-black bg-opacity-50 z-50 flex items-center justify-center p-6 animate-fade-in">
|
||||||
|
<div className="bg-white rounded-2xl max-w-2xl w-full p-8">
|
||||||
|
<h2 className="text-xl font-bold text-gray-900 mb-6">发送祝福消息</h2>
|
||||||
|
|
||||||
|
{/* 消息类型选择 */}
|
||||||
|
<div className="mb-6">
|
||||||
|
<label className="block text-sm font-medium text-gray-700 mb-2">
|
||||||
|
消息类型
|
||||||
|
</label>
|
||||||
|
<div className="flex gap-3">
|
||||||
|
<button className="flex-1 p-3 border-2 border-primary-600 bg-primary-50 text-primary-700 rounded-lg font-medium">
|
||||||
|
文字消息
|
||||||
|
</button>
|
||||||
|
<button className="flex-1 p-3 border-2 border-gray-200 text-gray-700 rounded-lg font-medium hover:border-gray-300">
|
||||||
|
语音消息
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* 消息内容 */}
|
||||||
|
<div className="mb-6">
|
||||||
|
<label className="block text-sm font-medium text-gray-700 mb-2">
|
||||||
|
消息内容
|
||||||
|
</label>
|
||||||
|
<textarea
|
||||||
|
value={messageText}
|
||||||
|
onChange={(e) => setMessageText(e.target.value)}
|
||||||
|
placeholder="请输入您想对老人说的话..."
|
||||||
|
rows={4}
|
||||||
|
className="w-full px-3 py-2 border border-gray-300 rounded-lg focus:ring-2 focus:ring-primary-500 focus:border-transparent resize-none"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* 投递时机 */}
|
||||||
|
<div className="mb-6">
|
||||||
|
<label className="block text-sm font-medium text-gray-700 mb-2">
|
||||||
|
投递时机
|
||||||
|
</label>
|
||||||
|
<select
|
||||||
|
value={deliveryTiming}
|
||||||
|
onChange={(e) => setDeliveryTiming(e.target.value)}
|
||||||
|
className="w-full px-3 py-2 border border-gray-300 rounded-lg focus:ring-2 focus:ring-primary-500 focus:border-transparent"
|
||||||
|
>
|
||||||
|
<option value="asap">尽快播报</option>
|
||||||
|
<option value="tonight">今晚 19:00 前</option>
|
||||||
|
<option value="emotion">情绪低落时</option>
|
||||||
|
<option value="after_meal">用餐后</option>
|
||||||
|
</select>
|
||||||
|
<p className="mt-1 text-xs text-gray-500">
|
||||||
|
数字人会在合适的时机转述您的消息
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* 快捷短语 */}
|
||||||
|
<div className="mb-6">
|
||||||
|
<label className="block text-sm font-medium text-gray-700 mb-2">
|
||||||
|
快捷短语
|
||||||
|
</label>
|
||||||
|
<div className="flex flex-wrap gap-2">
|
||||||
|
{[
|
||||||
|
'记得按时吃药',
|
||||||
|
'多喝水',
|
||||||
|
'天气转凉,注意保暖',
|
||||||
|
'想你了',
|
||||||
|
'我爱你',
|
||||||
|
].map((phrase, index) => (
|
||||||
|
<button
|
||||||
|
key={index}
|
||||||
|
onClick={() => setMessageText(phrase)}
|
||||||
|
className="px-3 py-1 bg-gray-100 text-gray-700 rounded-full text-sm hover:bg-gray-200 transition-colors"
|
||||||
|
>
|
||||||
|
{phrase}
|
||||||
|
</button>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* 操作按钮 */}
|
||||||
|
<div className="flex gap-3">
|
||||||
|
<button
|
||||||
|
onClick={() => setShowMessageComposer(false)}
|
||||||
|
className="flex-1 py-3 border border-gray-300 text-gray-700 rounded-lg hover:bg-gray-50 transition-colors"
|
||||||
|
>
|
||||||
|
取消
|
||||||
|
</button>
|
||||||
|
<button
|
||||||
|
onClick={handleSendMessage}
|
||||||
|
disabled={!messageText.trim()}
|
||||||
|
className="flex-1 py-3 bg-primary-600 text-white rounded-lg hover:bg-primary-700 transition-colors font-medium disabled:opacity-50 disabled:cursor-not-allowed flex items-center justify-center gap-2"
|
||||||
|
>
|
||||||
|
<Send size={18} />
|
||||||
|
发送
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
};
|
||||||
Reference in New Issue
Block a user