feat(mp): Phase 2 功能补全 — SOS+推送+趋势图tooltip+家属安全存储

- index: 添加 SOS 紧急求助悬浮按钮(仅患者可见)
- alerts: 告警页面添加微信推送订阅 + critical 推送标识
- TrendChart: 添加触摸 tooltip 显示日期和数值
- family: edit_patient 改用 secureSet/secureGet 安全存储
This commit is contained in:
iven
2026-05-21 16:24:40 +08:00
parent 6338cd7428
commit 4e9eb7b397
8 changed files with 159 additions and 6 deletions

View File

@@ -45,3 +45,21 @@
0% { background-position: 200% 0; }
100% { background-position: -200% 0; }
}
// ── Tooltip ──
.trend-tooltip {
position: absolute;
transform: translateX(-50%);
background: rgba(15, 23, 42, 0.9);
padding: 6px 12px;
border-radius: 6px;
white-space: nowrap;
pointer-events: none;
z-index: 10;
}
.trend-tooltip-text {
font-size: 12px;
color: #fff;
}

View File

@@ -1,4 +1,4 @@
import React, { useEffect, useRef, useCallback } from 'react';
import React, { useEffect, useRef, useCallback, useState } from 'react';
import { Canvas, View, Text } from '@tarojs/components';
import Taro from '@tarojs/taro';
import './index.scss';
@@ -41,6 +41,7 @@ export default React.memo(function TrendChart({
height = 500,
}: TrendChartProps) {
const canvasRef = useRef<any>(null);
const [tooltip, setTooltip] = useState<{ date: string; value: number; x: number } | null>(null);
const draw = useCallback(() => {
const node = canvasRef.current;
@@ -150,6 +151,26 @@ export default React.memo(function TrendChart({
ctx.restore();
}, [data, referenceMin, referenceMax]);
const handleTouchStart = useCallback((e: any) => {
if (!data || data.length === 0 || !canvasRef.current) return;
const touch = e.touches[0];
const node = canvasRef.current;
const dpr = getDPR();
const x = touch.x;
const w = node.width / dpr;
const pad = { left: 45, right: 15 };
const cw = w - pad.left - pad.right;
const relX = x - pad.left;
const idx = Math.round((relX / cw) * (data.length - 1));
if (idx >= 0 && idx < data.length) {
setTooltip({
date: data[idx].date,
value: data[idx].value,
x: pad.left + (idx / Math.max(data.length - 1, 1)) * cw,
});
}
}, [data]);
useEffect(() => {
const query = Taro.createSelectorQuery();
query
@@ -181,7 +202,18 @@ export default React.memo(function TrendChart({
type='2d'
id='trend-chart-canvas'
style={{ width: '100%', height: '100%' }}
onTouchStart={handleTouchStart}
/>
{tooltip && (
<View
className='trend-tooltip'
style={{ left: `${tooltip.x}px`, top: '8px' }}
>
<Text className='trend-tooltip-text'>
{tooltip.date}: {tooltip.value}{unit ? ` ${unit}` : ''}
</Text>
</View>
)}
</View>
);
});

View File

@@ -499,3 +499,43 @@
font-weight: 600;
color: $white;
}
// ── SOS 紧急求助按钮 ──
.sos-btn {
position: fixed;
right: 24px;
bottom: 140px;
width: 56px;
height: 56px;
border-radius: 50%;
background: #DC2626;
display: flex;
align-items: center;
justify-content: center;
box-shadow: 0 4px 12px rgba(220, 38, 38, 0.4);
z-index: 100;
&:active {
opacity: 0.8;
transform: scale(0.95);
}
}
.sos-btn-text {
font-size: 16px;
font-weight: 700;
color: #fff;
letter-spacing: 1px;
}
.elder-mode .sos-btn {
width: 68px;
height: 68px;
right: 20px;
bottom: 130px;
}
.elder-mode .sos-btn-text {
font-size: 20px;
}

View File

@@ -195,6 +195,35 @@ function GuestHome({ modeClass }: { modeClass: string }) {
// ─── 登录后首页 ───
function SOSButton() {
const user = useAuthStore((s) => s.user);
const isMedicalStaff = useAuthStore((s) => s.isMedicalStaff);
const currentPatient = useAuthStore((s) => s.currentPatient);
if (!user || !currentPatient || isMedicalStaff()) return null;
const handleSOS = async () => {
const { confirm } = await Taro.showModal({
title: '紧急求助',
content: '是否拨打急救电话?',
confirmText: '拨打',
cancelText: '取消',
});
if (confirm) {
Taro.makePhoneCall({
phoneNumber: '120',
fail: () => Taro.showToast({ title: '拨号失败', icon: 'none' }),
});
}
};
return (
<View className='sos-btn' onClick={handleSOS}>
<Text className='sos-btn-text'>SOS</Text>
</View>
);
}
function HomeDashboard({ modeClass }: { modeClass: string }) {
const {
healthItems, indicatorCapsules, completedCount, progressPercent,
@@ -302,6 +331,7 @@ function HomeDashboard({ modeClass }: { modeClass: string }) {
<Text className='action-btn-text'></Text>
</View>
</View>
<SOSButton />
</PageShell>
);
}

View File

@@ -126,3 +126,19 @@
color: $card;
font-weight: 500;
}
// ── 告警推送标识 ──
.alert-header-left {
display: flex;
align-items: center;
gap: 8px;
}
.alert-push-tag {
font-size: var(--tk-caption);
color: var(--tk-primary);
background: rgba(8, 145, 178, 0.1);
padding: 2px 8px;
border-radius: 4px;
}

View File

@@ -72,6 +72,15 @@ export default function PatientAlerts() {
async () => {
Taro.setNavigationBarTitle({ title: '健康告警' });
await fetchAlerts(1, status, true);
// 请求 critical 告警推送订阅
try {
const tmplId = process.env.TARO_APP_WX_TEMPLATE_CRITICAL_ALERT || '';
if (tmplId) {
await Taro.requestSubscribeMessage({ tmplIds: [tmplId] });
}
} catch {
// 用户拒绝或已订阅,不阻塞页面
}
},
{ throttleMs: 10000, enablePullDown: true },
);
@@ -111,8 +120,13 @@ export default function PatientAlerts() {
return (
<ContentCard className='alert-card' key={item.id}>
<View className='alert-header'>
<View className={`alert-badge ${sev.className}`}>
<Text className='alert-badge-text'>{sev.label}</Text>
<View className='alert-header-left'>
<View className={`alert-badge ${sev.className}`}>
<Text className='alert-badge-text'>{sev.label}</Text>
</View>
{item.severity === 'critical' && (
<Text className='alert-push-tag'></Text>
)}
</View>
<Text className='alert-time'>
{new Date(item.created_at).toLocaleDateString()}

View File

@@ -2,6 +2,7 @@ import React, { useState, useEffect } from 'react';
import { View, Text, Input, Picker } from '@tarojs/components';
import Taro, { useRouter } from '@tarojs/taro';
import { createPatient, updatePatient, Patient } from '../../../services/patient';
import { secureGet, secureRemove } from '@/utils/secure-storage';
import { useElderClass } from '../../../hooks/useElderClass';
import { useSafeTimeout } from '@/hooks/useSafeTimeout';
import PageShell from '@/components/ui/PageShell';
@@ -14,7 +15,8 @@ export default function FamilyAdd() {
const modeClass = useElderClass();
const router = useRouter();
const editId = router.params.id || '';
const editData = Taro.getStorageSync('edit_patient') as Patient | null;
const rawEdit = secureGet('edit_patient');
const editData: Patient | null = rawEdit ? JSON.parse(rawEdit) : null;
const [name, setName] = useState(editData?.name || '');
const [relationIdx, setRelationIdx] = useState(
@@ -28,7 +30,7 @@ export default function FamilyAdd() {
const { safeSetTimeout } = useSafeTimeout();
useEffect(() => {
return () => { Taro.removeStorageSync('edit_patient'); };
return () => { secureRemove('edit_patient'); };
}, []);
const handleSubmit = async () => {

View File

@@ -2,6 +2,7 @@ import React, { useState, useCallback } from 'react';
import { View, Text } from '@tarojs/components';
import Taro from '@tarojs/taro';
import { safeNavigateTo } from '@/utils/navigate';
import { secureSet } from '@/utils/secure-storage';
import { usePageData } from '@/hooks/usePageData';
import { listPatients, Patient } from '../../../services/patient';
import { useAuthStore } from '../../../stores/auth';
@@ -60,7 +61,7 @@ export default function FamilyList() {
};
const goToEdit = (patient: Patient) => {
Taro.setStorageSync('edit_patient', patient);
secureSet('edit_patient', JSON.stringify(patient));
safeNavigateTo(`/pages/pkg-profile/family-add/index?id=${patient.id}`);
};