feat(miniprogram): 老年友好版本全面重设计 — 5→4 Tab + 首页/健康/消息/我的重写
- TabBar 从 5 Tab 调整为 4 Tab(首页/健康/消息/我的) - 首页重写为 5 区域布局:问候+进度环+体征2x2+待办+快捷操作 - 健康页重写:体征录入大输入框+趋势柱状图+BLE设备卡片 - 新建消息页:咨询对话+系统通知双 Tab - 我的页调整:菜单高度64px+新增积分商城入口 - 设计系统更新:色彩对比度提升(WCAG AA)+触控参数+老年友好 mixin - 新增 ProgressRing 组件(CSS conic-gradient 实现) - 修复 diagnoses 页面 $suc-l 未定义变量
This commit is contained in:
@@ -9,9 +9,6 @@
|
||||
|
||||
/* ─── 页头 ─── */
|
||||
.health-header {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
padding: 24px 32px 8px;
|
||||
}
|
||||
|
||||
@@ -22,248 +19,272 @@
|
||||
color: $tx;
|
||||
}
|
||||
|
||||
.health-add-btn {
|
||||
background: $pri;
|
||||
padding: 10px 28px;
|
||||
/* ─── 类型 Tab ─── */
|
||||
.vital-tabs {
|
||||
display: flex;
|
||||
padding: 12px 24px;
|
||||
gap: 12px;
|
||||
}
|
||||
|
||||
.vital-tab {
|
||||
flex: 1;
|
||||
height: $tab-h;
|
||||
border-radius: $r;
|
||||
background: $surface-alt;
|
||||
@include flex-center;
|
||||
position: relative;
|
||||
|
||||
&:active {
|
||||
opacity: 0.85;
|
||||
}
|
||||
|
||||
&.vital-tab-active {
|
||||
background: $pri;
|
||||
|
||||
.vital-tab-text {
|
||||
color: #fff;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
.vital-tab-text {
|
||||
font-size: 26px;
|
||||
font-weight: 600;
|
||||
color: $tx2;
|
||||
}
|
||||
|
||||
.vital-tab-dot {
|
||||
position: absolute;
|
||||
top: 10px;
|
||||
right: 10px;
|
||||
width: 8px;
|
||||
height: 8px;
|
||||
border-radius: 50%;
|
||||
background: $wrn;
|
||||
}
|
||||
|
||||
/* ─── 录入区 ─── */
|
||||
.input-section {
|
||||
margin: 0 24px 24px;
|
||||
background: $card;
|
||||
border-radius: $r;
|
||||
padding: 24px;
|
||||
box-shadow: $shadow-sm;
|
||||
}
|
||||
|
||||
.input-group {
|
||||
margin-bottom: 20px;
|
||||
}
|
||||
|
||||
.input-label {
|
||||
font-size: 26px;
|
||||
color: $tx;
|
||||
font-weight: 600;
|
||||
display: block;
|
||||
margin-bottom: 12px;
|
||||
}
|
||||
|
||||
.input-field {
|
||||
height: 56px;
|
||||
background: $bg;
|
||||
border: 2px solid $bd;
|
||||
border-radius: $r-sm;
|
||||
padding: 0 20px;
|
||||
font-family: 'Georgia', 'Times New Roman', serif;
|
||||
font-size: 28px;
|
||||
color: $tx;
|
||||
width: 100%;
|
||||
box-sizing: border-box;
|
||||
}
|
||||
|
||||
.input-ref {
|
||||
font-size: 22px;
|
||||
color: $tx2;
|
||||
display: block;
|
||||
margin-top: 12px;
|
||||
}
|
||||
|
||||
/* ─── 血糖时段选择 ─── */
|
||||
.period-group {
|
||||
display: flex;
|
||||
gap: 12px;
|
||||
margin-top: 16px;
|
||||
}
|
||||
|
||||
.period-btn {
|
||||
flex: 1;
|
||||
height: $btn-primary-h;
|
||||
border-radius: $r-sm;
|
||||
background: $surface-alt;
|
||||
@include flex-center;
|
||||
|
||||
&.period-active {
|
||||
background: $pri;
|
||||
|
||||
.period-btn-text {
|
||||
color: #fff;
|
||||
}
|
||||
}
|
||||
|
||||
&:active {
|
||||
opacity: 0.85;
|
||||
}
|
||||
}
|
||||
|
||||
.health-add-text {
|
||||
.period-btn-text {
|
||||
font-size: 26px;
|
||||
color: #fff;
|
||||
font-weight: 600;
|
||||
color: $tx2;
|
||||
}
|
||||
|
||||
/* ─── 快捷操作 ─── */
|
||||
.health-actions-row {
|
||||
display: flex;
|
||||
flex-wrap: wrap;
|
||||
gap: 12px;
|
||||
padding: 16px 24px 24px;
|
||||
/* ─── 保存按钮 ─── */
|
||||
.save-btn {
|
||||
@include btn-primary;
|
||||
margin-top: 24px;
|
||||
border-radius: $r;
|
||||
}
|
||||
|
||||
.action-item {
|
||||
flex: 1;
|
||||
min-width: 100px;
|
||||
.save-btn-text {
|
||||
font-size: 28px;
|
||||
font-weight: 600;
|
||||
color: #fff;
|
||||
}
|
||||
|
||||
/* ─── 趋势图 ─── */
|
||||
.trend-section {
|
||||
margin: 0 24px 24px;
|
||||
}
|
||||
|
||||
.trend-empty {
|
||||
background: $card;
|
||||
border-radius: $r;
|
||||
padding: 20px 12px;
|
||||
padding: 36px;
|
||||
text-align: center;
|
||||
}
|
||||
|
||||
.trend-empty-text {
|
||||
font-size: 24px;
|
||||
color: $tx2;
|
||||
}
|
||||
|
||||
.trend-chart {
|
||||
background: $card;
|
||||
border-radius: $r;
|
||||
padding: 24px;
|
||||
box-shadow: $shadow-sm;
|
||||
}
|
||||
|
||||
.trend-bars {
|
||||
display: flex;
|
||||
align-items: flex-end;
|
||||
height: 200px;
|
||||
gap: 8px;
|
||||
}
|
||||
|
||||
.trend-bar-col {
|
||||
flex: 1;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
align-items: center;
|
||||
gap: 8px;
|
||||
box-shadow: $shadow-sm;
|
||||
height: 100%;
|
||||
justify-content: flex-end;
|
||||
}
|
||||
|
||||
&:active {
|
||||
opacity: 0.7;
|
||||
.trend-bar {
|
||||
width: 100%;
|
||||
max-width: 40px;
|
||||
border-radius: 6px 6px 0 0;
|
||||
min-height: 16px;
|
||||
|
||||
&.trend-bar-normal {
|
||||
background: $pri;
|
||||
}
|
||||
|
||||
&.trend-bar-warn {
|
||||
background: $wrn;
|
||||
}
|
||||
}
|
||||
|
||||
.action-icon {
|
||||
width: 72px;
|
||||
height: 72px;
|
||||
border-radius: 50%;
|
||||
@include flex-center;
|
||||
|
||||
&.icon-primary { background: $pri-l; }
|
||||
&.icon-accent { background: $acc-l; }
|
||||
&.icon-warn { background: $wrn-l; }
|
||||
.trend-bar-label {
|
||||
font-size: 22px;
|
||||
color: $tx2;
|
||||
margin-top: 8px;
|
||||
}
|
||||
|
||||
.action-char {
|
||||
font-family: 'Georgia', 'Times New Roman', serif;
|
||||
font-size: 32px;
|
||||
font-weight: bold;
|
||||
color: $pri;
|
||||
|
||||
.icon-accent & { color: $acc; }
|
||||
.icon-warn & { color: $wrn; }
|
||||
/* ─── BLE 设备卡片 ─── */
|
||||
.device-section {
|
||||
margin: 0 24px 16px;
|
||||
}
|
||||
|
||||
.action-label {
|
||||
font-size: 24px;
|
||||
color: $tx;
|
||||
font-weight: 500;
|
||||
}
|
||||
|
||||
/* ─── 通用 section ─── */
|
||||
.health-section {
|
||||
margin: 0 24px 28px;
|
||||
}
|
||||
|
||||
/* ─── 体征概览 ─── */
|
||||
.vitals-grid {
|
||||
display: grid;
|
||||
grid-template-columns: 1fr 1fr;
|
||||
gap: 16px;
|
||||
}
|
||||
|
||||
.vital-card {
|
||||
.device-card {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
background: $card;
|
||||
border-radius: $r;
|
||||
padding: 24px 20px;
|
||||
padding: 24px;
|
||||
box-shadow: $shadow-sm;
|
||||
transition: opacity 0.2s;
|
||||
|
||||
&:active {
|
||||
opacity: 0.7;
|
||||
opacity: 0.85;
|
||||
}
|
||||
}
|
||||
|
||||
.vital-label {
|
||||
.device-icon {
|
||||
width: 56px;
|
||||
height: 56px;
|
||||
border-radius: $r-sm;
|
||||
background: $pri-l;
|
||||
@include flex-center;
|
||||
margin-right: 16px;
|
||||
flex-shrink: 0;
|
||||
}
|
||||
|
||||
.device-icon-text {
|
||||
font-size: 26px;
|
||||
font-weight: bold;
|
||||
color: $pri;
|
||||
}
|
||||
|
||||
.device-info {
|
||||
flex: 1;
|
||||
min-width: 0;
|
||||
}
|
||||
|
||||
.device-name {
|
||||
font-size: 28px;
|
||||
font-weight: 600;
|
||||
color: $tx;
|
||||
display: block;
|
||||
margin-bottom: 4px;
|
||||
}
|
||||
|
||||
.device-desc {
|
||||
font-size: 22px;
|
||||
color: $tx2;
|
||||
display: block;
|
||||
margin-bottom: 10px;
|
||||
}
|
||||
|
||||
.vital-value {
|
||||
@include serif-number;
|
||||
font-size: 44px;
|
||||
font-weight: bold;
|
||||
color: $tx;
|
||||
display: block;
|
||||
margin-bottom: 8px;
|
||||
line-height: 1.1;
|
||||
}
|
||||
|
||||
.vital-bottom {
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
align-items: center;
|
||||
}
|
||||
|
||||
.vital-unit {
|
||||
font-size: 20px;
|
||||
.device-arrow {
|
||||
font-size: 32px;
|
||||
color: $tx3;
|
||||
flex-shrink: 0;
|
||||
}
|
||||
|
||||
.vital-tag {
|
||||
@include tag($acc-l, $acc);
|
||||
|
||||
&.tag-warn {
|
||||
@include tag($wrn-l, $wrn);
|
||||
}
|
||||
}
|
||||
|
||||
.vital-ref {
|
||||
font-size: 20px;
|
||||
color: $tx3;
|
||||
margin-top: 8px;
|
||||
display: block;
|
||||
}
|
||||
|
||||
.vital-bar-track {
|
||||
height: 6px;
|
||||
background: $bd-l;
|
||||
border-radius: 3px;
|
||||
margin-top: 12px;
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
.vital-bar-fill {
|
||||
height: 100%;
|
||||
border-radius: 3px;
|
||||
transition: width 0.3s ease;
|
||||
|
||||
&.bar-green { background: $acc; }
|
||||
&.bar-orange { background: $wrn; }
|
||||
&.bar-red { background: $dan; }
|
||||
}
|
||||
|
||||
/* ─── 趋势入口 — 水平滚动卡片 ─── */
|
||||
.trend-scroll {
|
||||
white-space: nowrap;
|
||||
width: 100%;
|
||||
}
|
||||
|
||||
.trend-card {
|
||||
display: inline-flex;
|
||||
flex-direction: column;
|
||||
align-items: center;
|
||||
width: 200px;
|
||||
/* ─── 健康资讯入口 ─── */
|
||||
.article-entry {
|
||||
margin: 0 24px 24px;
|
||||
background: $card;
|
||||
border-radius: $r;
|
||||
padding: 24px 16px;
|
||||
margin-right: 16px;
|
||||
padding: 24px;
|
||||
box-shadow: $shadow-sm;
|
||||
vertical-align: top;
|
||||
|
||||
&:active {
|
||||
opacity: 0.7;
|
||||
opacity: 0.85;
|
||||
}
|
||||
}
|
||||
|
||||
.trend-card-icon {
|
||||
width: 64px;
|
||||
height: 64px;
|
||||
border-radius: $r;
|
||||
background: $pri-l;
|
||||
@include flex-center;
|
||||
margin-bottom: 12px;
|
||||
}
|
||||
|
||||
.trend-card-char {
|
||||
font-family: 'Georgia', 'Times New Roman', serif;
|
||||
font-size: 28px;
|
||||
font-weight: bold;
|
||||
color: $pri;
|
||||
}
|
||||
|
||||
.trend-card-label {
|
||||
.article-entry-text {
|
||||
font-size: 26px;
|
||||
color: $tx;
|
||||
font-weight: 500;
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
.trend-card-arrow {
|
||||
font-size: 22px;
|
||||
color: $tx3;
|
||||
margin-top: 8px;
|
||||
}
|
||||
|
||||
/* ─── 最近监测 ─── */
|
||||
.record-card {
|
||||
background: $card;
|
||||
border-radius: $r;
|
||||
padding: 20px 24px;
|
||||
margin-bottom: 12px;
|
||||
box-shadow: $shadow-sm;
|
||||
}
|
||||
|
||||
.record-date {
|
||||
font-size: 24px;
|
||||
color: $pri;
|
||||
font-weight: 600;
|
||||
display: block;
|
||||
margin-bottom: 12px;
|
||||
}
|
||||
|
||||
.record-data {
|
||||
display: flex;
|
||||
gap: 32px;
|
||||
flex-wrap: wrap;
|
||||
}
|
||||
|
||||
.record-item {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 4px;
|
||||
}
|
||||
|
||||
.record-item-label {
|
||||
font-size: 22px;
|
||||
color: $tx3;
|
||||
}
|
||||
|
||||
.record-item-value {
|
||||
@include serif-number;
|
||||
font-size: 26px;
|
||||
color: $tx;
|
||||
font-weight: 500;
|
||||
}
|
||||
|
||||
@@ -1,235 +1,330 @@
|
||||
import { useState } from 'react';
|
||||
import { View, Text, ScrollView } from '@tarojs/components';
|
||||
import { View, Text, Input } from '@tarojs/components';
|
||||
import Taro, { useDidShow } from '@tarojs/taro';
|
||||
import { useHealthStore } from '../../stores/health';
|
||||
import { listDailyMonitoring, DailyMonitoring } from '../../services/health';
|
||||
import { usePointsStore } from '../../stores/points';
|
||||
import { useAuthStore } from '../../stores/auth';
|
||||
import { trackEvent } from '../../services/analytics';
|
||||
import { inputVitalSign, getTrend } from '../../services/health';
|
||||
import Loading from '../../components/Loading';
|
||||
import './index.scss';
|
||||
|
||||
const QUICK_ACTIONS = [
|
||||
{ label: '日常上报', char: '日', bg: 'icon-primary' },
|
||||
{ label: '体征录入', char: '录', bg: 'icon-accent' },
|
||||
{ label: '查看趋势', char: '势', bg: 'icon-warn' },
|
||||
type VitalType = 'blood_pressure' | 'heart_rate' | 'blood_sugar' | 'weight';
|
||||
|
||||
const VITAL_TABS: { key: VitalType; label: string }[] = [
|
||||
{ key: 'blood_pressure', label: '血压' },
|
||||
{ key: 'heart_rate', label: '心率' },
|
||||
{ key: 'blood_sugar', label: '血糖' },
|
||||
{ key: 'weight', label: '体重' },
|
||||
];
|
||||
|
||||
const TREND_LINKS = [
|
||||
{ label: '血压趋势', indicator: 'blood_pressure_systolic', char: '压' },
|
||||
{ label: '心率趋势', indicator: 'heart_rate', char: '率' },
|
||||
{ label: '血糖趋势', indicator: 'blood_sugar_fasting', char: '糖' },
|
||||
];
|
||||
const REF_RANGES: Record<VitalType, { range: string; warn: string }> = {
|
||||
blood_pressure: { range: '收缩压 90-140 / 舒张压 60-90 mmHg', warn: '血压偏高,确认提交?' },
|
||||
heart_rate: { range: '60-100 bpm', warn: '心率异常,确认提交?' },
|
||||
blood_sugar: { range: '空腹 3.9-6.1 / 餐后 <7.8 mmol/L', warn: '血糖偏高,确认提交?' },
|
||||
weight: { range: '根据 BMI 18.5-24 计算', warn: '' },
|
||||
};
|
||||
|
||||
function getStatusTag(status?: string) {
|
||||
if (status === 'high') return { label: '偏高', cls: 'tag-warn' };
|
||||
if (status === 'low') return { label: '偏低', cls: 'tag-warn' };
|
||||
if (status === 'normal') return { label: '正常', cls: 'tag-ok' };
|
||||
return null;
|
||||
}
|
||||
|
||||
/** 根据 status 计算 sparkline bar 的颜色 */
|
||||
function getBarColor(status?: string): string {
|
||||
if (status === 'normal') return 'bar-green';
|
||||
if (status === 'high' || status === 'low') return 'bar-orange';
|
||||
return 'bar-green';
|
||||
}
|
||||
|
||||
/** 计算数值在参考范围中的位置百分比 (0-100) */
|
||||
function getBarPercent(value: number | undefined, ref?: string): number {
|
||||
if (!value || !ref) return 50;
|
||||
const match = ref.match(/([\d.]+)\s*[-–]\s*([\d.]+)/);
|
||||
if (!match) return 50;
|
||||
const low = parseFloat(match[1]);
|
||||
const high = parseFloat(match[2]);
|
||||
if (high <= low) return 50;
|
||||
// 将值映射到 0-100 范围,参考范围占据中间 70%(15%-85%)
|
||||
const range = high - low;
|
||||
const normalized = (value - low + range * 0.3) / (range * 1.6);
|
||||
return Math.max(5, Math.min(95, normalized * 100));
|
||||
interface TrendPoint {
|
||||
date: string;
|
||||
value: number;
|
||||
}
|
||||
|
||||
export default function Health() {
|
||||
const { todaySummary, loading, refreshToday } = useHealthStore();
|
||||
const { checkinStatus, refresh: refreshPoints } = usePointsStore();
|
||||
const { todaySummary, loading, refreshToday, getTrend: fetchTrend } = useHealthStore();
|
||||
const { currentPatient } = useAuthStore();
|
||||
const [recentRecords, setRecentRecords] = useState<DailyMonitoring[]>([]);
|
||||
const [activeTab, setActiveTab] = useState<VitalType>('blood_pressure');
|
||||
const [systolic, setSystolic] = useState('');
|
||||
const [diastolic, setDiastolic] = useState('');
|
||||
const [heartRateVal, setHeartRateVal] = useState('');
|
||||
const [sugarVal, setSugarVal] = useState('');
|
||||
const [sugarPeriod, setSugarPeriod] = useState<'fasting' | 'postprandial'>('fasting');
|
||||
const [weightVal, setWeightVal] = useState('');
|
||||
const [saving, setSaving] = useState(false);
|
||||
const [trendData, setTrendData] = useState<TrendPoint[]>([]);
|
||||
const [trendLoading, setTrendLoading] = useState(false);
|
||||
|
||||
useDidShow(() => {
|
||||
refreshToday();
|
||||
refreshPoints();
|
||||
loadRecentRecords();
|
||||
loadTrend(activeTab);
|
||||
});
|
||||
|
||||
const loadRecentRecords = async () => {
|
||||
if (currentPatient) {
|
||||
try {
|
||||
const resp = await listDailyMonitoring(currentPatient.id, { page: 1, page_size: 3 });
|
||||
setRecentRecords(resp.data || []);
|
||||
} catch {
|
||||
// daily monitoring API 可能不可用
|
||||
const loadTrend = async (type: VitalType) => {
|
||||
setTrendLoading(true);
|
||||
try {
|
||||
const indicatorMap: Record<VitalType, string> = {
|
||||
blood_pressure: 'blood_pressure_systolic',
|
||||
heart_rate: 'heart_rate',
|
||||
blood_sugar: 'blood_sugar_fasting',
|
||||
weight: 'weight',
|
||||
};
|
||||
const points = await fetchTrend(indicatorMap[type], '7d');
|
||||
setTrendData(points);
|
||||
} catch {
|
||||
setTrendData([]);
|
||||
} finally {
|
||||
setTrendLoading(false);
|
||||
}
|
||||
};
|
||||
|
||||
const handleTabChange = (tab: VitalType) => {
|
||||
setActiveTab(tab);
|
||||
loadTrend(tab);
|
||||
};
|
||||
|
||||
const getWarnStatus = (type: VitalType): string | null => {
|
||||
if (type === 'blood_pressure') {
|
||||
const sys = parseFloat(systolic);
|
||||
const dia = parseFloat(diastolic);
|
||||
if (sys > 140 || dia > 90) return REF_RANGES.blood_pressure.warn;
|
||||
} else if (type === 'heart_rate') {
|
||||
const val = parseFloat(heartRateVal);
|
||||
if (val > 100 || val < 60) return REF_RANGES.heart_rate.warn;
|
||||
} else if (type === 'blood_sugar') {
|
||||
const val = parseFloat(sugarVal);
|
||||
if (sugarPeriod === 'fasting' && val > 6.1) return REF_RANGES.blood_sugar.warn;
|
||||
if (sugarPeriod === 'postprandial' && val > 7.8) return REF_RANGES.blood_sugar.warn;
|
||||
}
|
||||
return null;
|
||||
};
|
||||
|
||||
const handleSave = async () => {
|
||||
const patientId = currentPatient?.id;
|
||||
if (!patientId) {
|
||||
Taro.showToast({ title: '请先登录', icon: 'none' });
|
||||
return;
|
||||
}
|
||||
|
||||
const warnMsg = getWarnStatus(activeTab);
|
||||
if (warnMsg) {
|
||||
const { confirm } = await Taro.showModal({
|
||||
title: '异常提示',
|
||||
content: warnMsg,
|
||||
confirmText: '确认提交',
|
||||
cancelText: '再看看',
|
||||
});
|
||||
if (!confirm) return;
|
||||
}
|
||||
|
||||
setSaving(true);
|
||||
try {
|
||||
switch (activeTab) {
|
||||
case 'blood_pressure': {
|
||||
const sys = parseFloat(systolic);
|
||||
const dia = parseFloat(diastolic);
|
||||
if (!sys || !dia) { Taro.showToast({ title: '请填写完整', icon: 'none' }); return; }
|
||||
await inputVitalSign(patientId, {
|
||||
indicator_type: 'blood_pressure',
|
||||
value: sys,
|
||||
extra: { systolic: sys, diastolic: dia },
|
||||
});
|
||||
setSystolic('');
|
||||
setDiastolic('');
|
||||
break;
|
||||
}
|
||||
case 'heart_rate': {
|
||||
const val = parseFloat(heartRateVal);
|
||||
if (!val) { Taro.showToast({ title: '请填写心率', icon: 'none' }); return; }
|
||||
await inputVitalSign(patientId, { indicator_type: 'heart_rate', value: val });
|
||||
setHeartRateVal('');
|
||||
break;
|
||||
}
|
||||
case 'blood_sugar': {
|
||||
const val = parseFloat(sugarVal);
|
||||
if (!val) { Taro.showToast({ title: '请填写血糖值', icon: 'none' }); return; }
|
||||
await inputVitalSign(patientId, { indicator_type: 'blood_sugar', value: val });
|
||||
setSugarVal('');
|
||||
break;
|
||||
}
|
||||
case 'weight': {
|
||||
const val = parseFloat(weightVal);
|
||||
if (!val) { Taro.showToast({ title: '请填写体重', icon: 'none' }); return; }
|
||||
await inputVitalSign(patientId, { indicator_type: 'weight', value: val });
|
||||
setWeightVal('');
|
||||
break;
|
||||
}
|
||||
}
|
||||
Taro.showToast({ title: '保存成功', icon: 'success' });
|
||||
refreshToday(true);
|
||||
loadTrend(activeTab);
|
||||
} catch {
|
||||
Taro.showToast({ title: '保存失败', icon: 'none' });
|
||||
} finally {
|
||||
setSaving(false);
|
||||
}
|
||||
};
|
||||
|
||||
const goToInput = () => {
|
||||
Taro.navigateTo({ url: '/pages/pkg-health/input/index' });
|
||||
};
|
||||
|
||||
const goToDailyMonitoring = () => {
|
||||
Taro.navigateTo({ url: '/pages/pkg-health/daily-monitoring/index' });
|
||||
};
|
||||
|
||||
const goToTrend = (indicator: string) => {
|
||||
Taro.navigateTo({ url: `/pages/pkg-health/trend/index?indicator=${indicator}` });
|
||||
};
|
||||
|
||||
const goToMall = () => {
|
||||
Taro.switchTab({ url: '/pages/mall/index' });
|
||||
};
|
||||
|
||||
const summary = todaySummary || {};
|
||||
const items = [
|
||||
{ label: '血压', value: summary.blood_pressure ? `${summary.blood_pressure.systolic}/${summary.blood_pressure.diastolic}` : '--/--', unit: 'mmHg', indicator: 'blood_pressure_systolic', status: summary.blood_pressure?.status, ref: summary.blood_pressure?.reference_range, numValue: summary.blood_pressure?.systolic },
|
||||
{ label: '心率', value: summary.heart_rate ? `${summary.heart_rate.value}` : '--', unit: 'bpm', indicator: 'heart_rate', status: summary.heart_rate?.status, ref: summary.heart_rate?.reference_range, numValue: summary.heart_rate?.value },
|
||||
{ label: '血糖', value: summary.blood_sugar ? `${summary.blood_sugar.value}` : '--', unit: 'mmol/L', indicator: 'blood_sugar_fasting', status: summary.blood_sugar?.status, ref: summary.blood_sugar?.reference_range, numValue: summary.blood_sugar?.value },
|
||||
{ label: '体重', value: summary.weight ? `${summary.weight.value}` : '--', unit: 'kg', indicator: 'weight', status: summary.weight?.status, ref: summary.weight?.reference_range, numValue: summary.weight?.value },
|
||||
];
|
||||
|
||||
const quickActions = [
|
||||
{ ...QUICK_ACTIONS[0], action: goToDailyMonitoring },
|
||||
{ ...QUICK_ACTIONS[1], action: goToInput },
|
||||
{ ...QUICK_ACTIONS[2], action: () => goToTrend('blood_pressure_systolic') },
|
||||
];
|
||||
|
||||
const trendLinks = TREND_LINKS;
|
||||
|
||||
const formatBp = (record: DailyMonitoring) => {
|
||||
const parts: string[] = [];
|
||||
if (record.morning_bp_systolic && record.morning_bp_diastolic) {
|
||||
parts.push(`晨 ${record.morning_bp_systolic}/${record.morning_bp_diastolic}`);
|
||||
}
|
||||
if (record.evening_bp_systolic && record.evening_bp_diastolic) {
|
||||
parts.push(`晚 ${record.evening_bp_systolic}/${record.evening_bp_diastolic}`);
|
||||
}
|
||||
return parts.length > 0 ? parts.join(' ') : '--';
|
||||
};
|
||||
const maxTrendValue = Math.max(...trendData.map((d) => d.value), 1);
|
||||
const dayLabels = ['日', '一', '二', '三', '四', '五', '六'];
|
||||
|
||||
return (
|
||||
<View className='health-page'>
|
||||
{/* 页头 */}
|
||||
<View className='health-header'>
|
||||
<Text className='health-title'>健康数据</Text>
|
||||
<View className='health-add-btn' onClick={goToInput}>
|
||||
<Text className='health-add-text'>录入</Text>
|
||||
<Text className='health-title'>健康</Text>
|
||||
</View>
|
||||
|
||||
{/* 类型 Tab */}
|
||||
<View className='vital-tabs'>
|
||||
{VITAL_TABS.map((tab) => {
|
||||
const hasData = tab.key === 'blood_pressure' ? !!todaySummary?.blood_pressure
|
||||
: tab.key === 'heart_rate' ? !!todaySummary?.heart_rate
|
||||
: tab.key === 'blood_sugar' ? !!todaySummary?.blood_sugar
|
||||
: !!todaySummary?.weight;
|
||||
return (
|
||||
<View
|
||||
key={tab.key}
|
||||
className={`vital-tab ${activeTab === tab.key ? 'vital-tab-active' : ''}`}
|
||||
onClick={() => handleTabChange(tab.key)}
|
||||
>
|
||||
<Text className='vital-tab-text'>{tab.label}</Text>
|
||||
{!hasData && <View className='vital-tab-dot' />}
|
||||
</View>
|
||||
);
|
||||
})}
|
||||
</View>
|
||||
|
||||
{/* 录入区 */}
|
||||
<View className='input-section'>
|
||||
{activeTab === 'blood_pressure' && (
|
||||
<View className='input-group'>
|
||||
<Text className='input-label'>收缩压(高压)</Text>
|
||||
<Input
|
||||
className='input-field'
|
||||
type='number'
|
||||
placeholder='如 130'
|
||||
value={systolic}
|
||||
onInput={(e) => setSystolic(e.detail.value)}
|
||||
/>
|
||||
<Text className='input-label' style='margin-top:20px;'>舒张压(低压)</Text>
|
||||
<Input
|
||||
className='input-field'
|
||||
type='number'
|
||||
placeholder='如 85'
|
||||
value={diastolic}
|
||||
onInput={(e) => setDiastolic(e.detail.value)}
|
||||
/>
|
||||
<Text className='input-ref'>{REF_RANGES.blood_pressure.range}</Text>
|
||||
</View>
|
||||
)}
|
||||
|
||||
{activeTab === 'heart_rate' && (
|
||||
<View className='input-group'>
|
||||
<Text className='input-label'>心率</Text>
|
||||
<Input
|
||||
className='input-field'
|
||||
type='digit'
|
||||
placeholder='如 72'
|
||||
value={heartRateVal}
|
||||
onInput={(e) => setHeartRateVal(e.detail.value)}
|
||||
/>
|
||||
<Text className='input-ref'>{REF_RANGES.heart_rate.range}</Text>
|
||||
</View>
|
||||
)}
|
||||
|
||||
{activeTab === 'blood_sugar' && (
|
||||
<View className='input-group'>
|
||||
<Text className='input-label'>血糖值</Text>
|
||||
<Input
|
||||
className='input-field'
|
||||
type='digit'
|
||||
placeholder='如 5.6'
|
||||
value={sugarVal}
|
||||
onInput={(e) => setSugarVal(e.detail.value)}
|
||||
/>
|
||||
<View className='period-group'>
|
||||
<View
|
||||
className={`period-btn ${sugarPeriod === 'fasting' ? 'period-active' : ''}`}
|
||||
onClick={() => setSugarPeriod('fasting')}
|
||||
>
|
||||
<Text className='period-btn-text'>空腹</Text>
|
||||
</View>
|
||||
<View
|
||||
className={`period-btn ${sugarPeriod === 'postprandial' ? 'period-active' : ''}`}
|
||||
onClick={() => setSugarPeriod('postprandial')}
|
||||
>
|
||||
<Text className='period-btn-text'>餐后 2h</Text>
|
||||
</View>
|
||||
</View>
|
||||
<Text className='input-ref'>{REF_RANGES.blood_sugar.range}</Text>
|
||||
</View>
|
||||
)}
|
||||
|
||||
{activeTab === 'weight' && (
|
||||
<View className='input-group'>
|
||||
<Text className='input-label'>体重 (kg)</Text>
|
||||
<Input
|
||||
className='input-field'
|
||||
type='digit'
|
||||
placeholder='如 65.5'
|
||||
value={weightVal}
|
||||
onInput={(e) => setWeightVal(e.detail.value)}
|
||||
/>
|
||||
<Text className='input-ref'>{REF_RANGES.weight.range}</Text>
|
||||
</View>
|
||||
)}
|
||||
|
||||
<View className='save-btn' onClick={handleSave}>
|
||||
<Text className='save-btn-text'>{saving ? '保存中...' : '保存'}</Text>
|
||||
</View>
|
||||
</View>
|
||||
|
||||
{/* 快捷操作 + 打卡状态紧凑合并 */}
|
||||
<View className='health-actions-row'>
|
||||
{quickActions.map((a) => (
|
||||
<View className='action-item' key={a.label} onClick={a.action}>
|
||||
<View className={`action-icon ${a.bg}`}>
|
||||
<Text className='action-char'>{a.char}</Text>
|
||||
</View>
|
||||
<Text className='action-label'>{a.label}</Text>
|
||||
</View>
|
||||
))}
|
||||
{checkinStatus && (
|
||||
<View
|
||||
className='action-item checkin-badge'
|
||||
onClick={!checkinStatus.checked_in_today ? goToMall : undefined}
|
||||
>
|
||||
<View className={`action-icon ${checkinStatus.checked_in_today ? 'icon-accent' : 'icon-warn'}`}>
|
||||
<Text className='action-char'>卡</Text>
|
||||
</View>
|
||||
<Text className='action-label'>
|
||||
{checkinStatus.checked_in_today
|
||||
? (checkinStatus.consecutive_days > 0 ? `已打卡${checkinStatus.consecutive_days}天` : '已打卡')
|
||||
: '去打卡'}
|
||||
</Text>
|
||||
</View>
|
||||
)}
|
||||
</View>
|
||||
|
||||
{/* 今日体征概览 */}
|
||||
<View className='health-section'>
|
||||
<Text className='section-title'>今日体征</Text>
|
||||
{loading && !todaySummary ? (
|
||||
{/* 趋势图 */}
|
||||
<View className='trend-section'>
|
||||
<Text className='section-title'>近 7 天趋势</Text>
|
||||
{trendLoading ? (
|
||||
<Loading />
|
||||
) : trendData.length === 0 ? (
|
||||
<View className='trend-empty'>
|
||||
<Text className='trend-empty-text'>暂无趋势数据</Text>
|
||||
</View>
|
||||
) : (
|
||||
<View className='vitals-grid'>
|
||||
{items.map((item) => {
|
||||
const tag = getStatusTag(item.status);
|
||||
const barColor = getBarColor(item.status);
|
||||
const barPercent = getBarPercent(item.numValue, item.ref);
|
||||
return (
|
||||
<View className='vital-card' key={item.label} onClick={() => goToTrend(item.indicator)}>
|
||||
<Text className='vital-label'>{item.label}</Text>
|
||||
<Text className='vital-value'>{item.value}</Text>
|
||||
<View className='vital-bottom'>
|
||||
<Text className='vital-unit'>{item.unit}</Text>
|
||||
{tag && <Text className={`vital-tag ${tag.cls}`}>{tag.label}</Text>}
|
||||
<View className='trend-chart'>
|
||||
<View className='trend-bars'>
|
||||
{trendData.map((point, i) => {
|
||||
const heightPct = Math.max(8, (point.value / maxTrendValue) * 100);
|
||||
const isAbnormal = activeTab === 'blood_pressure' ? point.value > 140
|
||||
: activeTab === 'heart_rate' ? (point.value > 100 || point.value < 60)
|
||||
: activeTab === 'blood_sugar' ? point.value > 6.1
|
||||
: false;
|
||||
const dayOfWeek = new Date(point.date).getDay();
|
||||
return (
|
||||
<View className='trend-bar-col' key={i}>
|
||||
<View
|
||||
className={`trend-bar ${isAbnormal ? 'trend-bar-warn' : 'trend-bar-normal'}`}
|
||||
style={`height:${heightPct}%;`}
|
||||
/>
|
||||
<Text className='trend-bar-label'>{dayLabels[dayOfWeek]}</Text>
|
||||
</View>
|
||||
{/* Sparkline bar */}
|
||||
{item.ref && item.numValue != null && (
|
||||
<View className='vital-bar-track'>
|
||||
<View className={`vital-bar-fill ${barColor}`} style={`width: ${barPercent}%`} />
|
||||
</View>
|
||||
)}
|
||||
{item.ref && <Text className='vital-ref'>参考 {item.ref}</Text>}
|
||||
</View>
|
||||
);
|
||||
})}
|
||||
);
|
||||
})}
|
||||
</View>
|
||||
</View>
|
||||
)}
|
||||
</View>
|
||||
|
||||
{/* 趋势快捷入口 — 水平滚动卡片 */}
|
||||
<View className='health-section'>
|
||||
<Text className='section-title'>健康趋势</Text>
|
||||
<ScrollView className='trend-scroll' scrollX>
|
||||
{trendLinks.map((t) => (
|
||||
<View className='trend-card' key={t.label} onClick={() => goToTrend(t.indicator)}>
|
||||
<View className='trend-card-icon'>
|
||||
<Text className='trend-card-char'>{t.char}</Text>
|
||||
</View>
|
||||
<Text className='trend-card-label'>{t.label}</Text>
|
||||
<Text className='trend-card-arrow'>查看 ›</Text>
|
||||
</View>
|
||||
))}
|
||||
</ScrollView>
|
||||
{/* BLE 设备卡片 */}
|
||||
<View className='device-section'>
|
||||
<View
|
||||
className='device-card'
|
||||
onClick={() => Taro.navigateTo({ url: '/pages/device-sync/index' })}
|
||||
>
|
||||
<View className='device-icon'>
|
||||
<Text className='device-icon-text'>设</Text>
|
||||
</View>
|
||||
<View className='device-info'>
|
||||
<Text className='device-name'>蓝牙设备</Text>
|
||||
<Text className='device-desc'>连接设备自动同步数据</Text>
|
||||
</View>
|
||||
<Text className='device-arrow'>›</Text>
|
||||
</View>
|
||||
</View>
|
||||
|
||||
{/* 最近监测记录 */}
|
||||
{recentRecords.length > 0 && (
|
||||
<View className='health-section'>
|
||||
<Text className='section-title'>最近监测</Text>
|
||||
{recentRecords.map((record) => (
|
||||
<View className='record-card' key={record.id}>
|
||||
<Text className='record-date'>{record.record_date}</Text>
|
||||
<View className='record-data'>
|
||||
<View className='record-item'>
|
||||
<Text className='record-item-label'>血压</Text>
|
||||
<Text className='record-item-value'>{formatBp(record)}</Text>
|
||||
</View>
|
||||
{record.weight != null && (
|
||||
<View className='record-item'>
|
||||
<Text className='record-item-label'>体重</Text>
|
||||
<Text className='record-item-value'>{record.weight} kg</Text>
|
||||
</View>
|
||||
)}
|
||||
{record.blood_sugar != null && (
|
||||
<View className='record-item'>
|
||||
<Text className='record-item-label'>血糖</Text>
|
||||
<Text className='record-item-value'>{record.blood_sugar} mmol/L</Text>
|
||||
</View>
|
||||
)}
|
||||
</View>
|
||||
</View>
|
||||
))}
|
||||
</View>
|
||||
)}
|
||||
{/* 健康资讯入口 */}
|
||||
<View
|
||||
className='article-entry'
|
||||
onClick={() => Taro.navigateTo({ url: '/pages/article/index' })}
|
||||
>
|
||||
<Text className='article-entry-text'>最新健康资讯 ›</Text>
|
||||
</View>
|
||||
</View>
|
||||
);
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user