From 202c6dd0d2d19d52d50def3283939e0ad3de8942 Mon Sep 17 00:00:00 2001 From: iven Date: Wed, 29 Apr 2026 06:36:12 +0800 Subject: [PATCH] =?UTF-8?q?feat(miniprogram):=20=E5=B0=8F=E7=A8=8B?= =?UTF-8?q?=E5=BA=8F=E8=AE=BE=E5=A4=87=E6=95=B0=E6=8D=AE=E9=9B=86=E6=88=90?= =?UTF-8?q?=E6=89=93=E9=80=9A=20=E2=80=94=20Phase=203?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - 首页设备入口简化为直接跳转按钮(去除硬编码 never 状态) - 体征录入页增加「从设备同步」入口,设备数据自动回填表单 - 设备同步页支持 returnTo 参数,完成后返回录入页 - 医护工作台增加告警中心固定导航入口(带数字角标) --- .../src/pages/device-sync/index.tsx | 36 ++- apps/miniprogram/src/pages/doctor/index.scss | 21 ++ apps/miniprogram/src/pages/doctor/index.tsx | 9 +- apps/miniprogram/src/pages/index/index.scss | 60 +++++ apps/miniprogram/src/pages/index/index.tsx | 33 ++- .../src/pages/pkg-health/input/index.scss | 232 +++++++++++++++++ .../src/pages/pkg-health/input/index.tsx | 242 ++++++++++++++++++ 7 files changed, 615 insertions(+), 18 deletions(-) create mode 100644 apps/miniprogram/src/pages/pkg-health/input/index.scss create mode 100644 apps/miniprogram/src/pages/pkg-health/input/index.tsx diff --git a/apps/miniprogram/src/pages/device-sync/index.tsx b/apps/miniprogram/src/pages/device-sync/index.tsx index 8b9326d..0d17196 100644 --- a/apps/miniprogram/src/pages/device-sync/index.tsx +++ b/apps/miniprogram/src/pages/device-sync/index.tsx @@ -1,6 +1,6 @@ import { useState, useCallback } from 'react'; import { View, Text } from '@tarojs/components'; -import { useDidShow } from '@tarojs/taro'; +import Taro, { useDidShow, useRouter } from '@tarojs/taro'; import { BLEManager } from '@/services/ble/BLEManager'; import { XiaomiBandAdapter } from '@/services/ble/adapters/XiaomiBandAdapter'; import { BloodPressureAdapter } from '@/services/ble/adapters/BloodPressureAdapter'; @@ -19,6 +19,8 @@ type PageState = 'idle' | 'scanning' | 'connecting' | 'connected' | 'syncing' | export default function DeviceSync() { const { currentPatient } = useAuthStore(); + const router = useRouter(); + const returnTo = router.params.returnTo || ''; const [pageState, setPageState] = useState('idle'); const [devices, setDevices] = useState([]); const [selectedDevice, setSelectedDevice] = useState(null); @@ -86,6 +88,27 @@ export default function DeviceSync() { if (result.success) { setSyncCount(result.uploadedCount); setPageState('done'); + + // 如果从体征录入页跳转而来,将最新读数写入 storage 供回填 + if (returnTo === 'input' && liveReadings.length > 0) { + const mapped: Record = {}; + for (const r of liveReadings) { + if (r.device_type === 'blood_pressure') { + if (r.metric === 'systolic' && typeof r.values.value === 'number') mapped.systolic = r.values.value; + if (r.metric === 'diastolic' && typeof r.values.value === 'number') mapped.diastolic = r.values.value; + // 兼容 values 中直接包含 systolic/diastolic 的格式 + if (typeof r.values.systolic === 'number') mapped.systolic = r.values.systolic as number; + if (typeof r.values.diastolic === 'number') mapped.diastolic = r.values.diastolic as number; + } else if (r.device_type === 'blood_glucose' && typeof r.values.blood_glucose === 'number') { + mapped.blood_sugar = r.values.blood_glucose as number; + } else if (r.device_type === 'heart_rate' && typeof r.values.heart_rate === 'number') { + mapped.heart_rate = r.values.heart_rate as number; + } + } + if (Object.keys(mapped).length > 0) { + Taro.setStorageSync('device_sync_result', JSON.stringify(mapped)); + } + } } else { setErrorMsg(result.error || '同步失败'); setPageState('error'); @@ -94,7 +117,7 @@ export default function DeviceSync() { setErrorMsg(e.message || '同步失败'); setPageState('error'); } - }, [currentPatient, selectedDevice]); + }, [currentPatient, selectedDevice, liveReadings, returnTo]); const handleDisconnect = useCallback(async () => { await bleManager.disconnect(); @@ -189,8 +212,13 @@ export default function DeviceSync() { 同步完成 成功上传 {syncCount} 条数据 - - 完成 + { + handleDisconnect(); + if (returnTo === 'input') { + Taro.navigateBack(); + } + }}> + {returnTo === 'input' ? '返回录入' : '完成'} ); diff --git a/apps/miniprogram/src/pages/doctor/index.scss b/apps/miniprogram/src/pages/doctor/index.scss index 600642f..1f98575 100644 --- a/apps/miniprogram/src/pages/doctor/index.scss +++ b/apps/miniprogram/src/pages/doctor/index.scss @@ -174,9 +174,30 @@ font-family: 'Georgia', 'Times New Roman', serif; font-size: 28px; font-weight: 700; + } + + &__icon-wrap { + position: relative; + display: inline-flex; margin-bottom: 8px; } + &__badge { + position: absolute; + top: -6px; + right: -12px; + min-width: 32px; + height: 32px; + line-height: 32px; + text-align: center; + background: $dan; + color: #fff; + font-size: 18px; + font-weight: 700; + border-radius: $r-pill; + padding: 0 6px; + } + &__label { font-size: 24px; color: $tx2; diff --git a/apps/miniprogram/src/pages/doctor/index.tsx b/apps/miniprogram/src/pages/doctor/index.tsx index 25b0723..3587c47 100644 --- a/apps/miniprogram/src/pages/doctor/index.tsx +++ b/apps/miniprogram/src/pages/doctor/index.tsx @@ -30,7 +30,7 @@ const QUICK_ACTIONS = [ { label: '化验审核', initial: '审', route: '/pages/doctor/report/index' }, { label: '患者查询', initial: '查', route: '/pages/doctor/patients/index' }, { label: '随访记录', initial: '随', route: '/pages/doctor/followup/index' }, - { label: '排班查看', initial: '排', route: '/pages/doctor/patients/index' }, + { label: '告警中心', initial: '警', route: '/pages/doctor/alerts/index' }, ]; export default function DoctorHome() { @@ -143,7 +143,12 @@ export default function DoctorHome() { className='quick-action' onClick={() => Taro.navigateTo({ url: action.route })} > - {action.initial} + + {action.initial} + {action.label === '告警中心' && alertCount > 0 && ( + {alertCount > 99 ? '99+' : alertCount} + )} + {action.label} ))} diff --git a/apps/miniprogram/src/pages/index/index.scss b/apps/miniprogram/src/pages/index/index.scss index f5c4add..f6d7d32 100644 --- a/apps/miniprogram/src/pages/index/index.scss +++ b/apps/miniprogram/src/pages/index/index.scss @@ -327,3 +327,63 @@ font-size: 22px; color: $tx3; } + +/* ─── 设备快捷入口 ─── */ +.device-section { + margin: 0 24px 24px; +} + +.device-entry { + display: flex; + align-items: center; + background: $card; + border-radius: $r; + padding: 20px 24px; + margin-bottom: 12px; + box-shadow: $shadow-sm; + + &:active { + opacity: 0.7; + } +} + +.device-entry-icon-wrap { + width: 64px; + height: 64px; + border-radius: $r; + background: $pri-l; + display: flex; + align-items: center; + justify-content: center; + margin-right: 20px; + flex-shrink: 0; +} + +.device-entry-icon-text { + font-size: 32px; +} + +.device-entry-info { + flex: 1; + min-width: 0; +} + +.device-entry-name { + font-size: 28px; + font-weight: 600; + color: $tx; + display: block; + margin-bottom: 4px; +} + +.device-entry-desc { + font-size: 22px; + color: $tx3; + display: block; +} + +.device-entry-arrow { + font-size: 32px; + color: $tx3; + flex-shrink: 0; +} diff --git a/apps/miniprogram/src/pages/index/index.tsx b/apps/miniprogram/src/pages/index/index.tsx index 0f59a88..ab01e0b 100644 --- a/apps/miniprogram/src/pages/index/index.tsx +++ b/apps/miniprogram/src/pages/index/index.tsx @@ -3,7 +3,6 @@ import { useState } from 'react'; import Taro, { useDidShow } from '@tarojs/taro'; import { useAuthStore } from '../../stores/auth'; import { useHealthStore } from '../../stores/health'; -import DeviceCard from '../../components/DeviceCard'; import Loading from '../../components/Loading'; import { trackPageView } from '@/services/analytics'; import * as appointmentApi from '@/services/appointment'; @@ -123,18 +122,28 @@ export default function Index() { {new Date().toLocaleDateString('zh-CN', { month: 'long', day: 'numeric', weekday: 'short' })} - {/* 设备快捷入口 */} + {/* 设备快捷入口 — 点击直接跳转设备同步页面 */} - - + Taro.navigateTo({ url: '/pages/device-sync/index' })}> + + {'\u{1FA7A}'} + + + 血压计 + 蓝牙同步 · 自动采集 + + {'›'} + + Taro.navigateTo({ url: '/pages/device-sync/index' })}> + + {'\u{1FA78}'} + + + 血糖仪 + 蓝牙同步 · 自动采集 + + {'›'} + {/* 今日健康 */} diff --git a/apps/miniprogram/src/pages/pkg-health/input/index.scss b/apps/miniprogram/src/pages/pkg-health/input/index.scss new file mode 100644 index 0000000..f457d0c --- /dev/null +++ b/apps/miniprogram/src/pages/pkg-health/input/index.scss @@ -0,0 +1,232 @@ +@import '../../../styles/variables.scss'; +@import '../../../styles/mixins.scss'; + +.input-page { + min-height: 100vh; + background: $bg; + padding: 0 0 60px; +} + +/* ── hero ── */ +.input-hero { + padding: 48px 32px 36px; + display: flex; + flex-direction: column; + align-items: center; +} + +.input-hero-icon { + @include flex-center; + width: 88px; + height: 88px; + border-radius: $r-lg; + background: $pri-l; + margin-bottom: 20px; +} + +.input-hero-icon-text { + font-family: 'Georgia', 'Times New Roman', serif; + font-size: 40px; + font-weight: bold; + color: $pri; +} + +.input-hero-title { + @include section-title; + font-size: 36px; + margin-bottom: 8px; +} + +.input-hero-sub { + font-size: 24px; + color: $tx3; +} + +/* ── sync entry ── */ +.input-sync-entry { + display: flex; + align-items: center; + justify-content: space-between; + background: $card; + border-radius: $r; + box-shadow: $shadow-sm; + padding: 24px 28px; + margin: 0 24px 20px; + border: 1px dashed $pri; + + &:active { + opacity: 0.7; + } +} + +.input-sync-entry-text { + font-size: 28px; + font-weight: 600; + color: $pri; +} + +.input-sync-entry-hint { + font-size: 22px; + color: $tx3; +} + +/* ── card ── */ +.input-card { + background: $card; + border-radius: $r; + box-shadow: $shadow-md; + padding: 28px; + margin: 0 24px 20px; +} + +.input-card-header { + display: flex; + align-items: center; + gap: 14px; + margin-bottom: 20px; +} + +.input-card-indicator { + @include flex-center; + width: 44px; + height: 44px; + border-radius: $r-sm; + background: $acc-l; +} + +.input-card-indicator-char { + font-family: 'Georgia', 'Times New Roman', serif; + font-size: 24px; + font-weight: bold; + color: $acc; +} + +.input-card-label { + font-family: 'Georgia', 'Times New Roman', serif; + font-size: 28px; + font-weight: bold; + color: $tx; +} + +/* ── picker ── */ +.input-picker-row { + display: flex; + justify-content: space-between; + align-items: center; + background: $bg; + border-radius: $r-sm; + padding: 22px 24px; +} + +.input-picker-value { + font-size: 28px; + color: $tx; + @include serif-number; +} + +.input-picker-arrow { + font-family: 'Georgia', 'Times New Roman', serif; + font-size: 22px; + color: $tx3; + transform: rotate(180deg); + display: inline-block; +} + +/* ── section title ── */ +.input-section-title { + font-family: 'Georgia', 'Times New Roman', serif; + font-size: 28px; + font-weight: bold; + color: $tx; + margin-bottom: 16px; + display: block; +} + +/* ── blood pressure group ── */ +.input-bp-group { + display: flex; + align-items: flex-end; + gap: 12px; +} + +.input-bp-field { + flex: 1; +} + +.input-field-label { + font-size: 22px; + color: $tx2; + display: block; + margin-bottom: 8px; +} + +.input-bp-divider { + display: flex; + flex-direction: column; + align-items: center; + padding-bottom: 20px; + gap: 6px; +} + +.input-bp-line { + width: 16px; + height: 1px; + background: $bd; +} + +.input-bp-slash { + font-family: 'Georgia', 'Times New Roman', serif; + font-size: 36px; + color: $tx3; + font-weight: 300; +} + +/* ── input field ── */ +.input-field-box { + background: $bg; + border-radius: $r-sm; + padding: 20px 24px; + font-size: 28px; + color: $tx; + @include serif-number; + box-sizing: border-box; +} + +.input-field-full { + width: 100%; +} + +.input-field-unit { + font-size: 22px; + color: $tx3; + display: block; + margin-top: 10px; + font-style: italic; +} + +/* ── submit ── */ +.input-submit { + background: $pri; + border-radius: $r; + padding: 26px; + text-align: center; + margin: 48px 24px 0; + box-shadow: $shadow-md; + transition: opacity 0.2s; + + &:active { + opacity: 0.85; + } +} + +.input-submit-disabled { + opacity: 0.5; + box-shadow: none; +} + +.input-submit-text { + font-size: 32px; + color: white; + font-weight: bold; + letter-spacing: 2px; +} diff --git a/apps/miniprogram/src/pages/pkg-health/input/index.tsx b/apps/miniprogram/src/pages/pkg-health/input/index.tsx new file mode 100644 index 0000000..765c8f6 --- /dev/null +++ b/apps/miniprogram/src/pages/pkg-health/input/index.tsx @@ -0,0 +1,242 @@ +import { useState } from 'react'; +import { View, Text, Input, Picker } from '@tarojs/components'; +import Taro, { useDidShow } from '@tarojs/taro'; +import { z } from 'zod'; +import { inputVitalSign } from '../../../services/health'; +import { useAuthStore } from '../../../stores/auth'; +import { useHealthStore } from '@/stores/health'; +import { usePointsStore } from '@/stores/points'; +import { clearRequestCache } from '@/services/request'; +import { trackEvent } from '@/services/analytics'; +import './index.scss'; + +const INDICATORS = [ + { value: 'blood_pressure', label: '血压 (mmHg)' }, + { value: 'heart_rate', label: '心率 (bpm)' }, + { value: 'blood_sugar_fasting', label: '空腹血糖 (mmol/L)' }, + { value: 'blood_sugar_postprandial', label: '餐后血糖 (mmol/L)' }, + { value: 'weight', label: '体重 (kg)' }, + { value: 'temperature', label: '体温 (℃)' }, +]; + +const vitalSignSchema = z.object({ + indicator_type: z.enum(['blood_pressure', 'heart_rate', 'blood_sugar_fasting', 'blood_sugar_postprandial', 'weight', 'temperature']), + value: z.number().positive({ message: '请输入有效数值' }), + extra: z.object({ + systolic: z.number().min(60, '收缩压过低').max(250, '收缩压过高,请及时就医').optional(), + diastolic: z.number().min(40, '舒张压过低').max(150, '舒张压过高,请及时就医').optional(), + }).optional(), + note: z.string().max(200, '备注不能超过200字').optional(), +}); + +const WARN_THRESHOLDS: Record = { + blood_pressure: { max: 180, warning: '收缩压偏高,建议及时就医' }, + heart_rate: { max: 120, min: 50, warning: '心率异常,请注意休息' }, + blood_sugar_fasting: { max: 11.0, warning: '血糖偏高,建议就医检查' }, +}; + +export default function HealthInput() { + const [indicatorIdx, setIndicatorIdx] = useState(0); + const [value, setValue] = useState(''); + const [systolic, setSystolic] = useState(''); + const [diastolic, setDiastolic] = useState(''); + const [note, setNote] = useState(''); + const [submitting, setSubmitting] = useState(false); + const { currentPatient } = useAuthStore(); + const { clearCache } = useHealthStore(); + + /** 从 storage 中读取设备同步回传的数据并自动填充表单 */ + useDidShow(() => { + try { + const raw = Taro.getStorageSync('device_sync_result'); + if (!raw) return; + Taro.removeStorageSync('device_sync_result'); + + const syncData: Record = typeof raw === 'string' ? JSON.parse(raw) : raw; + + // 字段映射:设备同步数据 → 表单字段 + if (syncData.systolic != null && syncData.diastolic != null) { + // 有血压数据 → 切换到血压指标并填充 + setIndicatorIdx(0); + setSystolic(String(syncData.systolic)); + setDiastolic(String(syncData.diastolic)); + } else if (syncData.blood_sugar != null) { + setIndicatorIdx(2); // 空腹血糖 + setValue(String(syncData.blood_sugar)); + } else if (syncData.heart_rate != null) { + setIndicatorIdx(1); // 心率 + setValue(String(syncData.heart_rate)); + } + } catch { + // 解析失败则忽略,不影响正常使用 + } + }); + + const handleSubmit = async () => { + if (!currentPatient) { + Taro.showToast({ title: '请先选择就诊人', icon: 'none' }); + return; + } + + const currentIndicator = INDICATORS[indicatorIdx].value; + + if (currentIndicator === 'blood_pressure') { + if (!systolic || !diastolic) { + Taro.showToast({ title: '请填写收缩压和舒张压', icon: 'none' }); + return; + } + } else { + if (!value) { + Taro.showToast({ title: '请输入数值', icon: 'none' }); + return; + } + } + + const input = currentIndicator === 'blood_pressure' + ? { indicator_type: 'blood_pressure' as const, value: parseFloat(systolic), extra: { systolic: parseFloat(systolic), diastolic: parseFloat(diastolic) } } + : { indicator_type: currentIndicator as any, value: parseFloat(value) }; + + const result = vitalSignSchema.safeParse(input); + if (!result.success) { + Taro.showToast({ title: result.error.issues[0].message, icon: 'none' }); + return; + } + + const threshold = WARN_THRESHOLDS[currentIndicator]; + if (threshold) { + const val = input.value; + if ((threshold.max && val > threshold.max) || (threshold.min && val < threshold.min)) { + await Taro.showModal({ title: '健康提示', content: threshold.warning, showCancel: false }); + } + } + + setSubmitting(true); + try { + await inputVitalSign(currentPatient.id, { + ...input, + note: note || undefined, + }); + clearCache(); + clearRequestCache('/health/'); + usePointsStore.getState().invalidate(); + Taro.showToast({ title: '录入成功', icon: 'success' }); + trackEvent('health_data_input', { type: currentIndicator }); + setTimeout(() => Taro.navigateBack(), 1000); + } catch (e: unknown) { + const msg = e instanceof Error ? e.message : '录入失败'; + Taro.showToast({ title: msg, icon: 'none' }); + } finally { + setSubmitting(false); + } + }; + + const indicatorInitial = INDICATORS[indicatorIdx].label.charAt(0); + + return ( + + {/* 页面标题 */} + + + + + 体征录入 + 记录今日健康数据 + + + {/* 从设备同步入口 */} + Taro.navigateTo({ url: '/pages/device-sync/index?returnTo=input' })}> + 从设备同步 + 蓝牙连接设备自动获取数据 + + + {/* 指标类型选择 */} + + + + {indicatorInitial} + + 指标类型 + + i.label)} + value={indicatorIdx} + onChange={(e) => setIndicatorIdx(Number(e.detail.value))} + > + + {INDICATORS[indicatorIdx].label} + V + + + + + {/* 数值输入 */} + {INDICATORS[indicatorIdx].value === 'blood_pressure' ? ( + + 血压数值 + + + 收缩压 + setSystolic(e.detail.value)} + /> + + + + / + + + + 舒张压 + setDiastolic(e.detail.value)} + /> + + + mmHg + + ) : ( + + 检测数值 + setValue(e.detail.value)} + /> + + {INDICATORS[indicatorIdx].label.match(/\((.+)\)/)?.[1] || ''} + + + )} + + {/* 备注 */} + + 备注 + setNote(e.detail.value)} + /> + + + {/* 提交 */} + + {submitting ? '提交中...' : '提交录入'} + + + ); +}