healthMeterOld.vue 67 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435436437438439440441442443444445446447448449450451452453454455456457458459460461462463464465466467468469470471472473474475476477478479480481482483484485486487488489490491492493494495496497498499500501502503504505506507508509510511512513514515516517518519520521522523524525526527528529530531532533534535536537538539540541542543544545546547548549550551552553554555556557558559560561562563564565566567568569570571572573574575576577578579580581582583584585586587588589590591592593594595596597598599600601602603604605606607608609610611612613614615616617618619620621622623624625626627628629630631632633634635636637638639640641642643644645646647648649650651652653654655656657658659660661662663664665666667668669670671672673674675676677678679680681682683684685686687688689690691692693694695696697698699700701702703704705706707708709710711712713714715716717718719720721722723724725726727728729730731732733734735736737738739740741742743744745746747748749750751752753754755756757758759760761762763764765766767768769770771772773774775776777778779780781782783784785786787788789790791792793794795796797798799800801802803804805806807808809810811812813814815816817818819820821822823824825826827828829830831832833834835836837838839840841842843844845846847848849850851852853854855856857858859860861862863864865866867868869870871872873874875876877878879880881882883884885886887888889890891892893894895896897898899900901902903904905906907908909910911912913914915916917918919920921922923924925926927928929930931932933934935936937938939940941942943944945946947948949950951952953954955956957958959960961962963964965966967968969970971972973974975976977978979980981982983984985986987988989990991992993994995996997998999100010011002100310041005100610071008100910101011101210131014101510161017101810191020102110221023102410251026102710281029103010311032103310341035103610371038103910401041104210431044104510461047104810491050105110521053105410551056105710581059106010611062106310641065106610671068106910701071107210731074107510761077107810791080108110821083108410851086108710881089109010911092109310941095109610971098109911001101110211031104110511061107110811091110111111121113111411151116111711181119112011211122112311241125112611271128112911301131113211331134113511361137113811391140114111421143114411451146114711481149115011511152115311541155115611571158115911601161116211631164116511661167116811691170117111721173117411751176117711781179118011811182118311841185118611871188118911901191119211931194119511961197119811991200120112021203120412051206120712081209121012111212121312141215121612171218121912201221122212231224122512261227122812291230123112321233123412351236123712381239124012411242124312441245124612471248124912501251125212531254125512561257125812591260126112621263126412651266126712681269127012711272127312741275127612771278127912801281128212831284128512861287128812891290129112921293129412951296129712981299130013011302130313041305130613071308130913101311131213131314131513161317131813191320132113221323132413251326132713281329133013311332133313341335133613371338133913401341134213431344134513461347134813491350135113521353135413551356135713581359136013611362136313641365136613671368136913701371137213731374137513761377137813791380138113821383138413851386138713881389139013911392139313941395139613971398139914001401140214031404140514061407140814091410141114121413141414151416141714181419142014211422142314241425142614271428142914301431143214331434143514361437143814391440144114421443144414451446144714481449145014511452145314541455145614571458145914601461146214631464146514661467146814691470147114721473147414751476147714781479148014811482148314841485148614871488148914901491149214931494149514961497149814991500150115021503150415051506150715081509151015111512151315141515151615171518151915201521152215231524152515261527152815291530153115321533153415351536153715381539154015411542154315441545154615471548154915501551155215531554155515561557155815591560156115621563156415651566156715681569157015711572157315741575157615771578157915801581158215831584158515861587158815891590159115921593159415951596159715981599160016011602160316041605160616071608160916101611161216131614161516161617161816191620162116221623162416251626162716281629163016311632163316341635163616371638163916401641164216431644164516461647164816491650165116521653165416551656165716581659166016611662166316641665166616671668166916701671167216731674167516761677167816791680168116821683168416851686168716881689169016911692169316941695169616971698169917001701170217031704170517061707170817091710171117121713171417151716171717181719172017211722172317241725172617271728172917301731173217331734173517361737173817391740174117421743174417451746174717481749175017511752175317541755175617571758175917601761176217631764176517661767176817691770177117721773177417751776177717781779178017811782178317841785178617871788178917901791179217931794179517961797179817991800180118021803180418051806180718081809181018111812181318141815181618171818181918201821182218231824182518261827182818291830183118321833183418351836183718381839184018411842184318441845184618471848184918501851185218531854185518561857185818591860186118621863186418651866186718681869187018711872187318741875187618771878187918801881188218831884188518861887188818891890189118921893189418951896189718981899190019011902190319041905190619071908190919101911191219131914191519161917191819191920192119221923192419251926192719281929193019311932193319341935193619371938193919401941194219431944194519461947194819491950195119521953195419551956195719581959196019611962196319641965196619671968196919701971197219731974197519761977197819791980198119821983198419851986198719881989199019911992199319941995199619971998199920002001200220032004200520062007200820092010201120122013201420152016201720182019202020212022202320242025202620272028202920302031203220332034203520362037203820392040204120422043204420452046204720482049205020512052205320542055205620572058205920602061206220632064206520662067206820692070207120722073207420752076207720782079208020812082208320842085208620872088208920902091209220932094209520962097209820992100210121022103210421052106210721082109211021112112211321142115211621172118211921202121212221232124212521262127212821292130213121322133213421352136213721382139214021412142214321442145214621472148214921502151215221532154215521562157215821592160216121622163216421652166216721682169217021712172217321742175217621772178217921802181218221832184218521862187218821892190219121922193219421952196219721982199220022012202220322042205220622072208220922102211221222132214221522162217221822192220222122222223222422252226222722282229223022312232223322342235223622372238223922402241224222432244224522462247224822492250225122522253225422552256225722582259226022612262226322642265226622672268226922702271227222732274227522762277227822792280228122822283228422852286228722882289229022912292229322942295229622972298229923002301230223032304230523062307230823092310231123122313231423152316
  1. <template>
  2. <view class="page">
  3. <!-- 顶部导航 -->
  4. <!-- <view :style="{ height: statusBarHeight}"></view> -->
  5. <view class="nav-bar" :style="{ paddingTop: statusBarHeight}">
  6. <view class="nav-back" @tap="goBack">
  7. <!-- <text class="back-text">‹ 返回</text> -->
  8. <image class="back-text" src="@/static/images/pages_watch/icons/back_arrow_icon24.png"></image>
  9. </view>
  10. <view class="nav-title">小护士血压仪</view>
  11. <image class="next-text" src="@/static/images/pages_watch/icons/more_icon24.png"></image>
  12. </view>
  13. <view class="content">
  14. <!-- 顶部Tab -->
  15. <view class="tabs">
  16. <view class="tab" :class="{ active: activeTab === 'bp' }" @tap="activeTab = 'bp'">
  17. 血压/心率
  18. </view>
  19. <view class="tab" :class="{ active: activeTab === 'glucose' }" @tap="activeTab = 'glucose'">
  20. 血糖测量
  21. </view>
  22. <view class="tab" :class="{ active: activeTab === 'uric' }" @tap="activeTab = 'uric'">
  23. 尿酸测量
  24. </view>
  25. </view>
  26. <!-- 圆形仪表 -->
  27. <view class="meter-wrap">
  28. <view class="meter-ring" :style="ringStyle">
  29. <view class="meter-center">
  30. <!-- 准备就绪 -->
  31. <view v-if="uiState === 'ready'" class="ready-view">
  32. <!-- <view class="ready-check">✓</view> -->
  33. <image class="ready-checkimg" src="@/static/images/pages_watch/icons/ok_icon.png"></image>
  34. <view class="ready-text">准备就绪</view>
  35. </view>
  36. <!-- 量测中 -->
  37. <view v-else-if="uiState === 'measuring'" class="measuring-view">
  38. <view class="bp-value">
  39. {{ displaySystolic }}/{{ displayDiastolic }}
  40. <text class="bp-value-unit">mmHg</text>
  41. </view>
  42. <view class="measuring-sub">
  43. 正在自动加压..{{ ringProgressInt }}%
  44. </view>
  45. </view>
  46. <!-- 结果完成 -->
  47. <view v-else-if="uiState === 'result'" class="result-view">
  48. <view v-if="activeTab === 'bp'" class="result-view">
  49. <view class="bp-value">
  50. {{ lastBloodPressure.systolic }}/{{ lastBloodPressure.diastolic }}
  51. <text class="bp-value-unit">mmHg</text>
  52. </view>
  53. <view class="result-sub">测量完成</view>
  54. </view>
  55. <view v-else-if="activeTab === 'glucose'" class="result-view">
  56. <view class="bp-value">
  57. {{ lastGlucose.value }}
  58. <text class="bp-value-unit">{{ lastGlucose.unit }}</text>
  59. </view>
  60. <view class="result-sub">测量完成</view>
  61. </view>
  62. <view v-else class="result-view">
  63. <view class="bp-value">
  64. {{ lastUricAcid.value }}
  65. <text class="bp-value-unit">{{ lastUricAcid.unit }}</text>
  66. </view>
  67. <view class="result-sub">测量完成</view>
  68. </view>
  69. </view>
  70. <!-- 异常 -->
  71. <view v-else-if="uiState === 'error'" class="error-view">
  72. <view v-if="activeTab === 'bp'" class="result-view">
  73. <view class="bp-value error-num">
  74. --/--
  75. <text class="bp-value-unit">mmHg</text>
  76. </view>
  77. <view class="result-sub error-sub">测量错误</view>
  78. </view>
  79. <view v-else-if="activeTab === 'glucose'" class="result-view">
  80. <view class="bp-value error-num">
  81. --
  82. <text class="bp-value-unit">{{ lastGlucose.unit }}</text>
  83. </view>
  84. <view class="result-sub error-sub">{{ glucoseErrorMsg ? '血糖测量错误' : '测量错误' }}</view>
  85. </view>
  86. <view v-else class="result-view">
  87. <view class="bp-value error-num">
  88. --
  89. <text class="bp-value-unit">{{ lastUricAcid.unit }}</text>
  90. </view>
  91. <view class="result-sub error-sub">{{ uricAcidErrorMsg ? '尿酸测量错误' : '测量错误' }}</view>
  92. </view>
  93. </view>
  94. <!-- 未连接 -->
  95. <view v-else class="ready-view">
  96. <view class="ready-check disconnected-check">○</view>
  97. <view class="ready-text">未连接</view>
  98. </view>
  99. </view>
  100. </view>
  101. </view>
  102. <!-- 提示/同步卡片 -->
  103. <view v-if="uiState !== 'result'" class="tip-box">
  104. <view class="tip-icon">i</view>
  105. <view class="tip-text">{{ tipText }}</view>
  106. </view>
  107. <view v-else-if="bpUiState === 'result' && activeTab === 'bp'" class="sync-card">
  108. <view class="sync-left">
  109. <view class="sync-title">数据已同步</view>
  110. <view class="sync-time">测量时间:{{ lastSyncTime }}</view>
  111. </view>
  112. <image class="sync-check" src="@/static/images/pages_watch/icons/remove_icon2.png"></image>
  113. </view>
  114. <!-- 主按钮 -->
  115. <view v-if="activeTab === 'bp' && (bpUiState === 'ready' || bpUiState === 'error')" class="primary-btn" @tap="onPrimaryActionTap">
  116. <text class="primary-btn-text">{{ bpUiState === 'error' ? '重新测量' : '开始测量' }}</text>
  117. </view>
  118. <view v-else-if="activeTab === 'bp' && bpUiState === 'measuring'" class="primary-btn primary-btn-disabled">
  119. <text class="primary-btn-text">测量中</text>
  120. </view>
  121. <!-- 已连接信息(截图底部样式) -->
  122. <!-- <view v-if="connected" class="conn-card">
  123. <view class="conn-row">
  124. <view class="conn-dot" />
  125. <text class="conn-text">{{ deviceDisplayName }}已通过蓝牙连接({{ connectionPercent }}%)</text>
  126. </view>
  127. <view v-if="lastSyncTime" class="conn-time">最后同步时间:{{ lastSyncTime }}</view>
  128. </view> -->
  129. <!-- 结果指标卡片(只在血压/心率Tab展示,保持布局一致) -->
  130. <view v-if="bpUiState === 'result' && activeTab === 'bp'" class="result-panel">
  131. <view class="result-grid">
  132. <view class="result-card">
  133. <view class="result-label">血压</view>
  134. <view class="result-value">
  135. {{ lastBloodPressure.systolic }}/{{ lastBloodPressure.diastolic }}
  136. <text class="result-unit">(mmHg)</text>
  137. </view>
  138. <view class="result-chart">
  139. <image class="chart-img" src="@/static/images/pages_watch/icons/bloodP.png"></image>
  140. <!-- <svg viewBox="0 0 120 40" width="100%" height="40">
  141. <polyline fill="none" stroke="#FF7700" stroke-width="3" stroke-linecap="round" stroke-linejoin="round"
  142. points="8,28 18,28 25,20 34,26 46,14 58,22 68,18 80,26 95,12 110,20" />
  143. </svg> -->
  144. </view>
  145. <view class="result-text" :class="bpResultText=='偏高'?'active':''">{{ bpResultText }}</view>
  146. </view>
  147. <view class="result-card">
  148. <view class="result-label">心率</view>
  149. <view class="result-value">
  150. {{ lastBloodPressure.pulse }}
  151. <text class="result-unit">bpm</text>
  152. </view>
  153. <view class="result-chart">
  154. <image class="chart-img" src="@/static/images/pages_watch/icons/heart.png"></image>
  155. <!-- <svg viewBox="0 0 120 40" width="100%" height="40">
  156. <polyline fill="none" stroke="#FF4D4F" stroke-width="3" stroke-linecap="round" stroke-linejoin="round"
  157. points="6,26 14,28 20,20 28,24 34,18 42,22 50,16 58,20 66,14 74,18 82,10 90,16 98,12 106,16" />
  158. </svg> -->
  159. </view>
  160. <view class="result-text" :class="pulseResultText=='偏高'?'active':''">{{ pulseResultText }}</view>
  161. </view>
  162. <view class="result-card">
  163. <view class="result-label">血糖</view>
  164. <view class="result-value">
  165. {{ lastGlucose.value }}
  166. <text class="result-unit">({{ lastGlucose.unit }})</text>
  167. </view>
  168. <view class="result-chart">
  169. <image class="chart-img" :src="glucoseResultText=='偏高'?'/static/images/pages_watch/icons/bloods-top.png':'/static/images/pages_watch/icons/bloods.png'"></image>
  170. <!-- <svg viewBox="0 0 120 40" width="100%" height="40">
  171. <polyline fill="none" stroke="#52D087" stroke-width="3" stroke-linecap="round" stroke-linejoin="round"
  172. points="10,26 25,20 40,24 55,12 70,16 85,8 100,18 112,10" />
  173. <circle cx="25" cy="20" r="4" fill="#52D087" />
  174. <circle cx="55" cy="12" r="4" fill="#52D087" />
  175. <circle cx="85" cy="8" r="4" fill="#52D087" />
  176. </svg> -->
  177. </view>
  178. <view class="result-text" :class="glucoseResultText=='偏高'?'active':''">{{ glucoseResultText }}</view>
  179. </view>
  180. <view class="result-card">
  181. <view class="result-label">
  182. 尿酸
  183. <text class="trend-badge">{{ uricTrendText }}</text>
  184. </view>
  185. <view class="result-value">
  186. {{ lastUricAcid.value }}
  187. <text class="result-unit">({{ lastUricAcid.unit }})</text>
  188. </view>
  189. <view class="result-chart">
  190. <image class="chart-img" :src="uricResultText=='偏高'?'/static/images/pages_watch/icons/acid-top.png':'/static/images/pages_watch/icons/acid.png'"></image>
  191. <!-- <svg viewBox="0 0 120 40" width="100%" height="40">
  192. <g fill="#FF7700">
  193. <rect x="10" y="22" width="6" height="16" rx="2" />
  194. <rect x="20" y="18" width="6" height="20" rx="2" />
  195. <rect x="30" y="12" width="6" height="26" rx="2" />
  196. <rect x="40" y="16" width="6" height="22" rx="2" />
  197. <rect x="50" y="10" width="6" height="28" rx="2" />
  198. <rect x="60" y="14" width="6" height="24" rx="2" />
  199. <rect x="70" y="6" width="6" height="32" rx="2" />
  200. <rect x="80" y="12" width="6" height="26" rx="2" />
  201. <rect x="90" y="9" width="6" height="29" rx="2" />
  202. <rect x="100" y="13" width="6" height="25" rx="2" />
  203. </g>
  204. </svg> -->
  205. </view>
  206. <view class="result-text" :class="uricResultText=='偏高'?'active':''">{{ uricResultText }}</view>
  207. </view>
  208. </view>
  209. </view>
  210. <!-- 连接/设备操作区(非截图区域:保持你现有连接逻辑) -->
  211. <view v-if="!connected" class="actions">
  212. <view v-if="boundDevice">
  213. <view class="list-title">已绑定设备</view>
  214. <view class="device-item active" style="margin-bottom: 24rpx; justify-content: space-between">
  215. <view class="device-info">
  216. <text class="device-name">{{ boundDevice.name }}</text>
  217. <text class="device-id">{{ boundDevice.deviceId }}</text>
  218. </view>
  219. </view>
  220. <button class="btn btn-connect" :disabled="connecting" :loading="connecting" @tap="connectDevice(false)">
  221. {{ connecting ? '连接中...' : '重试连接' }}
  222. </button>
  223. <button class="btn btn-warn" :disabled="disconnecting" @tap="unbindDevice">解绑</button>
  224. </view>
  225. <view v-else>
  226. <button class="btn btn-primary" :disabled="scanning" :loading="scanning" @tap="startScan">
  227. {{ scanning ? '扫描中...' : '扫描设备' }}
  228. </button>
  229. <view v-if="deviceList.length > 0" class="device-list">
  230. <view class="list-title">可选设备(请确保血压仪/血糖仪已开机)</view>
  231. <view
  232. v-for="d in deviceList"
  233. :key="d.deviceId"
  234. class="device-item"
  235. :class="{ active: selectedDeviceId === d.deviceId }"
  236. @tap="selectDevice(d)"
  237. >
  238. <radio :checked="selectedDeviceId === d.deviceId" color="#FF5C03" />
  239. <view class="device-info">
  240. <text class="device-name">{{ d.name || d.localName || '未知设备' }}</text>
  241. <text class="device-id">{{ d.deviceId }}</text>
  242. </view>
  243. </view>
  244. </view>
  245. <button class="btn btn-connect" :disabled="!selectedDeviceId || connecting" :loading="connecting" @tap="connectDevice(false)">
  246. {{ connecting ? '连接中...' : '连接设备' }}
  247. </button>
  248. </view>
  249. </view>
  250. <!-- 日志(调试用,可折叠) -->
  251. <view class="log-section">
  252. <view class="log-title" @tap="showLog = !showLog">
  253. <text>运行日志</text>
  254. <text class="log-toggle">{{ showLog ? '收起' : '展开' }}</text>
  255. </view>
  256. <scroll-view v-if="showLog" class="log-list" scroll-y>
  257. <text v-for="(line, i) in logs" :key="i" class="log-line">{{ line }}</text>
  258. </scroll-view>
  259. </view>
  260. </view>
  261. <!-- <view v-if="footerPreText" class="footer-txt">
  262. <view class="pre"> {{ footerPreText }}</view>
  263. <view v-if="lastSyncTime" class="time">最后同步时间:{{ lastSyncTime }}</view>
  264. </view> -->
  265. </view>
  266. </template>
  267. <script>
  268. /**
  269. * 将 uni-app 的回调式 API 包装为 Promise 风格,便于使用 async/await 避免回调地狱
  270. * @param {string} api - uni-app 的 API 名称 (如 'openBluetoothAdapter')
  271. * @param {Object} opts - 传递给 API 的参数对象
  272. * @returns {Promise} 成功时 resolve 返回结果,失败时 reject 返回错误信息
  273. */
  274. const promisifyUniApi = (api, opts = {}) =>
  275. new Promise((res, rej) => {
  276. opts.success = res;
  277. opts.fail = rej;
  278. uni[api](opts);
  279. });
  280. // 解析 BLE 血压测量特征值(Bluetooth GATT 0x2A18 格式)
  281. // 固定 UUID:接收用 0xFFF1,发送用 0xFFF2,同属 Service 0xFFF0
  282. // 血压协议:命令头 0xFD,0xFD;命令尾 0x0D,0x0A
  283. const BP_HEADER = [0xfd, 0xfd];
  284. const BP_TAIL = [0x0d, 0x0a];
  285. // 血压协议命令 (FD FD ... 0D 0A) - 新协议
  286. const CMD_BP_BROADCAST_A4 = 0xa4;
  287. const CMD_BP_CONNECT_FA = 0xfa; // FA 05
  288. const CMD_BP_CONFIRM_06 = 0x06;
  289. const CMD_BP_PRESSURE_FB = 0xfb;
  290. const CMD_BP_RESULT_FC = 0xfc;
  291. const CMD_BP_ERROR_FD = 0xfd;
  292. // 血糖/尿酸协议命令 (A5 A5 ... 5A 5A)
  293. const GU_HEADER = [0xa5, 0xa5];
  294. const GU_TAIL = [0x5a, 0x5a];
  295. const CMD_GU_START_B1 = 0xb1;
  296. const CMD_GU_GLUCOSE_RES_B2 = 0xb2;
  297. const CMD_GU_URIC_RES_B3 = 0xb3;
  298. const CMD_GU_GLUCOSE_ERR_B4 = 0xb4;
  299. const CMD_GU_URIC_ERR_B5 = 0xb5;
  300. const CMD_GU_REPLY_START_C1 = 0xc1;
  301. const CMD_GU_REPLY_GLUCOSE_RES_C2 = 0xc2;
  302. const CMD_GU_REPLY_GLUCOSE_ERR_C3 = 0xc3;
  303. const CMD_GU_REPLY_URIC_RES_C4 = 0xc4;
  304. const CMD_GU_REPLY_URIC_ERR_C5 = 0xc5;
  305. // 构造血压指令
  306. function buildBpCommand(cmd, data = []) {
  307. const buf = new Uint8Array(2 + 1 + data.length + 2);
  308. buf[0] = 0xfd;
  309. buf[1] = 0xfd;
  310. buf[2] = cmd;
  311. for (let i = 0; i < data.length; i++) {
  312. buf[3 + i] = data[i];
  313. }
  314. buf[buf.length - 2] = 0x0d;
  315. buf[buf.length - 1] = 0x0a;
  316. return buf.buffer;
  317. }
  318. // 构造血糖/尿酸指令
  319. function buildGuCommand(cmd, data = []) {
  320. const len = 1 + data.length + 1; // 帧长包括 指令类型、数据、校验和
  321. const buf = new Uint8Array(2 + 1 + len + 2); // 帧头(2) + 帧长(1) + len + 帧尾(2)
  322. buf[0] = 0xa5;
  323. buf[1] = 0xa5;
  324. buf[2] = len;
  325. buf[3] = cmd;
  326. let checksum = cmd;
  327. for (let i = 0; i < data.length; i++) {
  328. buf[4 + i] = data[i];
  329. checksum += data[i];
  330. }
  331. buf[buf.length - 3] = checksum & 0xff;
  332. buf[buf.length - 2] = 0x5a;
  333. buf[buf.length - 1] = 0x5a;
  334. return buf.buffer;
  335. }
  336. function parseBpPacket(arr) {
  337. // 查找 BP_HEADER (0xfd, 0xfd)
  338. let startIndex = -1;
  339. for (let i = 0; i < arr.length - 1; i++) {
  340. if (arr[i] === 0xfd && arr[i + 1] === 0xfd) {
  341. startIndex = i;
  342. break;
  343. }
  344. }
  345. if (startIndex === -1) return null;
  346. // 查找 BP_TAIL (0x0d, 0x0a)
  347. let endIndex = -1;
  348. for (let i = startIndex + 2; i < arr.length - 1; i++) {
  349. if (arr[i] === 0x0d && arr[i + 1] === 0x0a) {
  350. endIndex = i + 1; // 包含 0x0a
  351. break;
  352. }
  353. }
  354. if (endIndex === -1) return null;
  355. const cmd = arr[startIndex + 2];
  356. const data = arr.slice(startIndex + 3, endIndex - 1);
  357. return { cmd, data };
  358. }
  359. // 构造旧协议指令 (D1/D2/D3等)
  360. function buildV2Command(cmd, data = []) {
  361. const packetLen = 1 + data.length + 1; // Cmd(1) + Data(n) + Checksum(1)
  362. const buf = new Uint8Array(2 + 1 + packetLen + 2);
  363. buf[0] = 0xfd;
  364. buf[1] = 0xfd;
  365. buf[2] = packetLen;
  366. buf[3] = cmd;
  367. let checksum = cmd;
  368. for (let i = 0; i < data.length; i++) {
  369. buf[4 + i] = data[i];
  370. checksum += data[i];
  371. }
  372. buf[buf.length - 3] = checksum & 0xff;
  373. buf[buf.length - 2] = 0x0d;
  374. buf[buf.length - 1] = 0x0a;
  375. return buf.buffer;
  376. }
  377. function parseGuPacket(arr) {
  378. // 查找 GU_HEADER (0xa5, 0xa5)
  379. let startIndex = -1;
  380. for (let i = 0; i < arr.length - 1; i++) {
  381. if (arr[i] === 0xa5 && arr[i + 1] === 0xa5) {
  382. startIndex = i;
  383. break;
  384. }
  385. }
  386. if (startIndex === -1) return null;
  387. const len = arr[startIndex + 2];
  388. const expectedEndIndex = startIndex + 5 + len - 1;
  389. if (expectedEndIndex >= arr.length) return null;
  390. if (arr[expectedEndIndex - 1] !== 0x5a || arr[expectedEndIndex] !== 0x5a) return null;
  391. const cmd = arr[startIndex + 3];
  392. const dataLen = len - 2; // len = cmd(1) + data + CS(1)
  393. const data = arr.slice(startIndex + 4, startIndex + 4 + dataLen);
  394. const checksum = arr[startIndex + 4 + dataLen];
  395. let sum = cmd;
  396. for (let i = 0; i < data.length; i++) sum += data[i];
  397. if ((sum & 0xff) !== checksum) {
  398. console.log('GU Checksum error:', sum & 0xff, checksum);
  399. return null;
  400. }
  401. return { cmd, data };
  402. }
  403. // 解析血糖/尿酸数据包
  404. function parseGlucosePacket(buffer) {
  405. // 解析新协议 (A5 A5)
  406. const newPacket = parseGuPacket(new Uint8Array(buffer));
  407. if (newPacket) {
  408. const { cmd, data } = newPacket;
  409. // 1. 开始测量 (B1)
  410. if (cmd === CMD_GU_START_B1) {
  411. const type = data[1]; // 0x01=血糖, 0x02=尿酸
  412. if (type === 0x01) {
  413. return {
  414. type: 'glucose_start',
  415. user: data[0],
  416. replyCmd: buildGuCommand(CMD_GU_REPLY_START_C1)
  417. };
  418. } else if (type === 0x02) {
  419. return {
  420. type: 'uric_start',
  421. user: data[0],
  422. replyCmd: buildGuCommand(CMD_GU_REPLY_START_C1)
  423. };
  424. }
  425. }
  426. // 2. 血糖结果 (B2)
  427. if (cmd === CMD_GU_GLUCOSE_RES_B2) {
  428. if (data.length >= 4) {
  429. const valH = data[1];
  430. const valL = data[2];
  431. const unitByte = data[3];
  432. const value = (valH << 8) | valL;
  433. let finalValue = value;
  434. if (value === 0) {
  435. finalValue = 'LO';
  436. } else if (value === 0xffff) {
  437. finalValue = 'HI';
  438. } else {
  439. finalValue = (value / 10).toFixed(1);
  440. }
  441. return {
  442. type: 'glucose_result',
  443. value: finalValue,
  444. unit: unitByte === 0x01 ? 'mg/dL' : 'mmol/L',
  445. time: Date.now(),
  446. replyCmd: buildGuCommand(CMD_GU_REPLY_GLUCOSE_RES_C2)
  447. };
  448. }
  449. }
  450. // 3. 尿酸结果 (B3)
  451. if (cmd === CMD_GU_URIC_RES_B3) {
  452. if (data.length >= 4) {
  453. const valH = data[1];
  454. const valL = data[2];
  455. const unitByte = data[3];
  456. const value = (valH << 8) | valL;
  457. let finalValue = value;
  458. if (value === 0) {
  459. finalValue = 'LO';
  460. } else if (value === 0xffff) {
  461. finalValue = 'HI';
  462. }
  463. return {
  464. type: 'uric_result',
  465. value: finalValue,
  466. unit: unitByte === 0x01 ? 'mg/dL' : 'umol/L',
  467. time: Date.now(),
  468. replyCmd: buildGuCommand(CMD_GU_REPLY_URIC_RES_C4) // 文档中尿酸结果回复是C4
  469. };
  470. }
  471. }
  472. // 4. 报错 (B4/B5)
  473. if (cmd === CMD_GU_GLUCOSE_ERR_B4) {
  474. const errCode = data[1];
  475. return {
  476. type: 'glucose_error',
  477. msg: '血糖测量错误: ' + getErrorMsg(errCode),
  478. replyCmd: buildGuCommand(CMD_GU_REPLY_GLUCOSE_ERR_C3)
  479. };
  480. }
  481. if (cmd === CMD_GU_URIC_ERR_B5) {
  482. const errCode = data[1];
  483. return {
  484. type: 'uric_error',
  485. msg: '尿酸测量错误: ' + getErrorMsg(errCode),
  486. replyCmd: buildGuCommand(CMD_GU_REPLY_URIC_ERR_C5) // 尿酸报错回复C5
  487. };
  488. }
  489. }
  490. return null;
  491. }
  492. function getErrorMsg(code) {
  493. const msgs = {
  494. 0x01: '温度超范围',
  495. 0x02: '试纸已使用过',
  496. 0x03: '试纸损坏或中途拔出'
  497. // 其他错误码...
  498. };
  499. return msgs[code] || '错误码 ' + code;
  500. }
  501. export default {
  502. data() {
  503. return {
  504. scanning: false,
  505. connecting: false,
  506. disconnecting: false,
  507. connected: false,
  508. deviceList: [],
  509. selectedDeviceId: '',
  510. selectedDeviceName: '',
  511. deviceId: '',
  512. serviceId: '',
  513. characteristicId: '',
  514. notifyServiceId: '',
  515. notifyCharId: '',
  516. writeServiceId: '',
  517. writeCharId: '',
  518. writeType: '', // 明确记录使用的写入类型: 'write' 或 'writeNoResponse'
  519. lastGlucose: {
  520. value: null,
  521. unit: 'mmol/L',
  522. time: null
  523. },
  524. // 血糖测量状态提示(如:请滴入血样)
  525. glucoseStatus: '',
  526. // 血糖测量错误提示
  527. glucoseErrorMsg: '',
  528. lastUricAcid: {
  529. value: null,
  530. unit: 'umol/L',
  531. time: null
  532. },
  533. // 尿酸测量状态提示
  534. uricAcidStatus: '',
  535. // 尿酸测量错误提示
  536. uricAcidErrorMsg: '',
  537. lastBloodPressure: {
  538. systolic: null,
  539. diastolic: null,
  540. pulse: null,
  541. ihb: null, // 0 正常,1 心律不齐
  542. time: null
  543. },
  544. // 血压计已回复 FD FD 06 0D 0A,已开始量测
  545. bpMeasureStarted: false,
  546. // 是否由用户主动点击开始测量
  547. userRequestedMeasure: false,
  548. bpWaitUser: 0, // A4广播中的用户号
  549. // 量测过程中实时压力值(FD FD FB PressureH PressureL 0D 0A),单位 mmHg,收到最终结果后清空
  550. bpCurrentPressure: null,
  551. // 量测错误提示(E-1/E-2/E-3/E-5/E-E/E-B),收到成功结果或断开时清空
  552. bpErrorMsg: '',
  553. // 新增:血压测量流程状态
  554. bpProcessStatus: '', // 'connecting', 'handshake', 'measuring', 'result', 'error'
  555. bpProcessStep: '', // 详细步骤描述
  556. logs: [],
  557. showLog: true,
  558. statusText: '未连接',
  559. statusBarHeight: uni.getSystemInfoSync().statusBarHeight + 'px',
  560. boundDevice: null
  561. ,
  562. // 顶部Tab:血压/血糖/尿酸(只影响展示,不改变蓝牙业务逻辑)
  563. activeTab: 'bp'
  564. };
  565. },
  566. computed: {
  567. deviceDisplayName() {
  568. return this.selectedDeviceName || (this.boundDevice && this.boundDevice.name) || '小护士血压仪';
  569. },
  570. bpUiState() {
  571. if (!this.connected) return 'disconnected';
  572. if (this.bpProcessStatus === 'result') return 'result';
  573. if (this.bpProcessStatus === 'measuring') return 'measuring';
  574. if (this.bpProcessStatus === 'error') return 'error';
  575. return 'ready';
  576. //return 'result';
  577. },
  578. // uiState:根据当前Tab展示对应的结果/提示状态(不改变蓝牙业务逻辑)
  579. uiState() {
  580. if (this.activeTab === 'bp') return this.bpUiState;
  581. if (!this.connected) return 'disconnected';
  582. if (this.activeTab === 'glucose') {
  583. if (this.glucoseErrorMsg) return 'error';
  584. if (this.lastGlucose.value !== null) return 'result';
  585. return 'ready';
  586. }
  587. if (this.activeTab === 'uric') {
  588. if (this.uricAcidErrorMsg) return 'error';
  589. if (this.lastUricAcid.value !== null) return 'result';
  590. return 'ready';
  591. }
  592. return 'ready';
  593. },
  594. // 环形进度:用实时压力估算(用于样式展示),结果完成时为100%
  595. ringProgressInt() {
  596. if (this.activeTab !== 'bp') return this.uiState === 'result' ? 100 : 0;
  597. if (this.bpUiState === 'result') return 100;
  598. if (this.bpUiState !== 'measuring') return 0;
  599. if (this.bpCurrentPressure == null) return 0;
  600. const p = (Number(this.bpCurrentPressure) - 50) / 100; // 假设 50~150mmHg 对应 0~100%
  601. return Math.max(0, Math.min(100, Math.round(p * 100)));
  602. },
  603. ringProgress() {
  604. return this.ringProgressInt;
  605. },
  606. ringStyle() {
  607. const track = '#EAEAEA';
  608. const tabArcColor = this.activeTab === 'glucose' ? '#52D087' : '#FF7700';
  609. const arcActive = (this.activeTab === 'bp' && (this.bpUiState === 'measuring' || this.bpUiState === 'result')) || (this.activeTab !== 'bp' && this.uiState === 'result');
  610. const arc = arcActive ? tabArcColor : '#EAEAEA';
  611. return {
  612. '--p': this.ringProgress + '%',
  613. '--arc': arc,
  614. '--track': track
  615. };
  616. },
  617. displaySystolic() {
  618. if (this.lastBloodPressure.systolic != null) return this.lastBloodPressure.systolic;
  619. if (this.bpCurrentPressure == null) return '--';
  620. return Math.round(Number(this.bpCurrentPressure));
  621. },
  622. displayDiastolic() {
  623. if (this.lastBloodPressure.diastolic != null) return this.lastBloodPressure.diastolic;
  624. if (this.bpCurrentPressure == null) return '--';
  625. // UI展示近似:根据实时压力估算舒张压占比
  626. return Math.round(Number(this.bpCurrentPressure) * 0.64);
  627. },
  628. connectionPercent() {
  629. return this.connected ? 85 : 0;
  630. },
  631. lastSyncTime() {
  632. // 优先用血压时间,其次血糖/尿酸
  633. return this.lastBloodPressure.time ?? this.lastGlucose.time ?? this.lastUricAcid.time ?? null;
  634. },
  635. footerPreText() {
  636. const deviceName = this.deviceDisplayName || '设备';
  637. if (this.connected) {
  638. return `小护士血压仪已通过蓝牙连接(${this.connectionPercent}%)`;
  639. }
  640. if (this.boundDevice) {
  641. return `小护士血压仪待连接`;
  642. }
  643. return '';
  644. },
  645. footerTimeText() {
  646. const timeVal = this.lastSyncTime;
  647. if (timeVal == null || timeVal === '') return '最后同步时间:--';
  648. // 统一显示为:YYYY-MM-DD HH:mm(不带秒,避免 toLocaleString 受地区影响)
  649. let d;
  650. if (typeof timeVal === 'number') {
  651. d = new Date(timeVal);
  652. } else {
  653. d = new Date(timeVal);
  654. }
  655. if (Number.isNaN(d.getTime())) return `最后同步时间:${String(timeVal)}`;
  656. const pad2 = (n) => String(n).padStart(2, '0');
  657. const formatted = `${d.getFullYear()}-${pad2(d.getMonth() + 1)}-${pad2(d.getDate())} ${pad2(d.getHours())}:${pad2(d.getMinutes())}`;
  658. return `最后同步时间:${formatted}`;
  659. },
  660. uricTrendText() {
  661. // 1:偏低 0:正常 2:偏高(简单阈值用于样式标签)
  662. const v = Number(this.lastUricAcid.value);
  663. if (!isFinite(v) || this.lastUricAcid.value === null) return ' ';
  664. // 常用阈值:150~416umol/L(与项目页面一致)
  665. if (v < 150) return '偏低';
  666. if (v > 416) return '偏高';
  667. return '正常';
  668. },
  669. bpResultText() {
  670. const sbp = Number(this.lastBloodPressure.systolic);
  671. const dbp = Number(this.lastBloodPressure.diastolic);
  672. if (!isFinite(sbp) || !isFinite(dbp) || this.lastBloodPressure.systolic == null || this.lastBloodPressure.diastolic == null) return '--';
  673. if (sbp < 90 || dbp < 60) return '偏低';
  674. if (sbp >= 140 || dbp >= 90) return '偏高';
  675. return '正常';
  676. },
  677. pulseResultText() {
  678. const pulse = Number(this.lastBloodPressure.pulse);
  679. if (!isFinite(pulse) || this.lastBloodPressure.pulse == null) return '--';
  680. if (pulse < 60) return '偏低';
  681. if (pulse > 100) return '偏高';
  682. return '正常';
  683. },
  684. glucoseResultText() {
  685. const rawValue = Number(this.lastGlucose.value);
  686. if (!isFinite(rawValue) || this.lastGlucose.value == null) return '--';
  687. const unit = String(this.lastGlucose.unit || '').toLowerCase();
  688. // 统一换算到 mmol/L 后再判断,避免不同单位阈值不一致
  689. const mmolValue = unit.includes('mg/dl') ? rawValue / 18 : rawValue;
  690. if (!isFinite(mmolValue)) return '--';
  691. if (mmolValue < 3.9) return '偏低';
  692. if (mmolValue > 6.1) return '偏高';
  693. return '正常';
  694. },
  695. uricResultText() {
  696. const rawValue = Number(this.lastUricAcid.value);
  697. if (!isFinite(rawValue) || this.lastUricAcid.value == null) return '--';
  698. const unit = String(this.lastUricAcid.unit || '').toLowerCase();
  699. // 尿酸统一按 umol/L 判断:mg/dL × 59.48 = umol/L
  700. const uricUmol = unit.includes('mg/dl') ? rawValue * 59.48 : rawValue;
  701. if (!isFinite(uricUmol)) return '--';
  702. if (uricUmol < 150) return '偏低';
  703. if (uricUmol > 416) return '偏高';
  704. return '正常';
  705. },
  706. tipText() {
  707. if (this.activeTab === 'bp') return '请保持坐姿,手臂平放桌面,袖带边缘距肘节2-3厘米。';
  708. if (this.activeTab === 'glucose') {
  709. if (this.glucoseErrorMsg) return '⚠️ ' + this.glucoseErrorMsg;
  710. if (this.glucoseStatus) return this.glucoseStatus;
  711. return '请滴入样本...';
  712. }
  713. if (this.activeTab === 'uric') {
  714. if (this.uricAcidErrorMsg) return '⚠️ ' + this.uricAcidErrorMsg;
  715. if (this.uricAcidStatus) return this.uricAcidStatus;
  716. return '请滴入样本...';
  717. }
  718. return '';
  719. }
  720. },
  721. onLoad() {
  722. uni.onBLEConnectionStateChange((res) => {
  723. console.log(`[BLE Connection] deviceId: ${res.deviceId}, connected: ${res.connected}`);
  724. if (res.deviceId === this.deviceId && !res.connected) {
  725. this.connected = false;
  726. this.statusText = '连接已断开';
  727. this.bpProcessStatus = 'idle';
  728. this.bpProcessStep = '设备连接已断开,请重新开启设备';
  729. this.log('设备蓝牙连接已断开');
  730. this.userRequestedMeasure = false;
  731. this.bpMeasureStarted = false;
  732. this.bpCurrentPressure = null;
  733. }
  734. });
  735. // const sys = uni.getSystemInfoSync();
  736. // this.statusBarHeight = sys.statusBarHeight;
  737. const bound = uni.getStorageSync('bound_health_device');
  738. if (bound) {
  739. try {
  740. this.boundDevice = JSON.parse(bound);
  741. this.selectedDeviceId = this.boundDevice.deviceId;
  742. this.selectedDeviceName = this.boundDevice.name;
  743. // 延迟启动自动连接
  744. setTimeout(() => {
  745. this.autoConnect();
  746. }, 500);
  747. } catch (e) {}
  748. }
  749. },
  750. onUnload() {
  751. this.closeBLE();
  752. },
  753. methods: {
  754. /**
  755. * 页面进入后尝试自动连接已绑定设备,仅做连接与握手,不触发测量
  756. * - 读取本地绑定信息,打开蓝牙适配器
  757. * - 调用 connectDevice(false) 建立连接
  758. */
  759. goBack() {
  760. uni.navigateBack({ delta: 1 });
  761. },
  762. onPrimaryActionTap() {
  763. // 准备/异常时允许点击:不改动原有的测量逻辑,只做UI状态保护
  764. if (!this.connected) {
  765. uni.showToast({ title: '未连接', icon: 'none' });
  766. return;
  767. }
  768. if (this.uiState === 'measuring') return;
  769. if (typeof this.startBpMeasurement === 'function') this.startBpMeasurement();
  770. },
  771. async autoConnect() {
  772. if (!this.boundDevice) return;
  773. this.log('尝试自动连接绑定设备...');
  774. try {
  775. await promisifyUniApi('openBluetoothAdapter');
  776. await this.connectDevice(false);
  777. } catch (e) {
  778. this.log('蓝牙未开启或异常,自动连接取消');
  779. }
  780. },
  781. //开启后台扫描寻找绑定设备...
  782. async startBackgroundScanForBoundDevice() {
  783. if (this.scanning) return;
  784. this.log('开启后台扫描寻找绑定设备...');
  785. this.scanning = true;
  786. try {
  787. await promisifyUniApi('startBluetoothDevicesDiscovery', {
  788. allowDuplicatesKey: false
  789. });
  790. uni.onBluetoothDeviceFound((res) => {
  791. const newDevices = res.devices || [];
  792. const found = newDevices.find((d) => d.deviceId === this.boundDevice.deviceId);
  793. if (found) {
  794. this.log('扫描到绑定设备,发起连接...');
  795. this.stopScan();
  796. this.connectDevice(true);
  797. }
  798. });
  799. } catch (e) {
  800. this.scanning = false;
  801. this.log('后台扫描启动失败');
  802. }
  803. },
  804. unbindDevice() {
  805. uni.showModal({
  806. title: '解除绑定',
  807. content: '确定要解除绑定该设备吗?解绑后需重新扫描连接。',
  808. success: async (res) => {
  809. if (res.confirm) {
  810. if (this.connected) {
  811. await this.disconnect();
  812. }
  813. uni.removeStorageSync('bound_health_device');
  814. this.boundDevice = null;
  815. this.selectedDeviceId = '';
  816. this.selectedDeviceName = '';
  817. this.deviceList = [];
  818. this.log('设备已解绑');
  819. }
  820. }
  821. });
  822. },
  823. log(msg) {
  824. const line = `${new Date().toLocaleTimeString()} ${msg}`;
  825. this.logs.unshift(line);
  826. if (this.logs.length > 100) this.logs.pop();
  827. },
  828. async startScan() {
  829. this.deviceList = [];
  830. this.selectedDeviceId = '';
  831. this.log('打开蓝牙适配器...');
  832. console.log('打开蓝牙适配器...')
  833. try {
  834. await promisifyUniApi('openBluetoothAdapter');
  835. this.log('开始扫描低功耗蓝牙设备...');
  836. await promisifyUniApi('startBluetoothDevicesDiscovery', {
  837. allowDuplicatesKey: false
  838. });
  839. this.scanning = true;
  840. this.statusText = '正在扫描...';
  841. uni.onBluetoothDeviceFound((res) => {
  842. const nameContains = (d) => {
  843. const name = (d.name || d.localName || '').toLowerCase();
  844. return name.indexOf('bluetooth') !== -1;
  845. };
  846. const newDevices = (res.devices || []).filter((d) => nameContains(d) && !this.deviceList.some((x) => x.deviceId === d.deviceId));
  847. if (newDevices.length) {
  848. this.deviceList = [...this.deviceList, ...newDevices];
  849. }
  850. });
  851. setTimeout(() => this.stopScan(), 10000);
  852. } catch (e) {
  853. this.scanning = false;
  854. this.statusText = '未连接';
  855. const msg = (e.errMsg || e.message || String(e)).toLowerCase();
  856. if (msg.indexOf('bluetooth') !== -1 || msg.indexOf('adapter') !== -1) {
  857. uni.showToast({ title: '请开启手机蓝牙', icon: 'none' });
  858. }
  859. this.log('扫描失败:' + (e.errMsg || e));
  860. }
  861. },
  862. async stopScan() {
  863. if (!this.scanning) return;
  864. try {
  865. await promisifyUniApi('stopBluetoothDevicesDiscovery');
  866. } catch (e) {}
  867. this.scanning = false;
  868. this.statusText = this.connected ? '已连接' : '未连接';
  869. this.log('扫描已停止,共发现 ' + this.deviceList.length + ' 个设备');
  870. },
  871. selectDevice(d) {
  872. this.selectedDeviceId = d.deviceId;
  873. this.selectedDeviceName = d.name || d.localName || '未知设备';
  874. },
  875. async sendData(buffer, description = '数据') {
  876. if (!this.deviceId || !this.writeServiceId || !this.writeCharId) return;
  877. const arr = new Uint8Array(buffer);
  878. const hex = Array.from(arr)
  879. .map((b) => ('0' + b.toString(16)).slice(-2))
  880. .join(' ');
  881. console.log(`[BLE Send Raw] ${description} Hex:`, hex);
  882. // 默认使用检测到的 writeType,如果没有则默认为 'write'
  883. let currentType = this.writeType;
  884. if (!currentType) {
  885. // 如果没有检测到明确的类型,尝试先用 write,如果失败会自动切
  886. currentType = 'write';
  887. }
  888. // 特殊处理:如果是 iOS 且没有明确 writeType,有些设备可能必须用 writeNoResponse
  889. const sys = uni.getSystemInfoSync();
  890. if (sys.platform === 'ios' && !this.writeType) {
  891. // iOS 下对某些外设,writeNoResponse 更稳定
  892. // currentType = 'writeNoResponse';
  893. }
  894. try {
  895. await promisifyUniApi('writeBLECharacteristicValue', {
  896. deviceId: this.deviceId,
  897. serviceId: this.writeServiceId,
  898. characteristicId: this.writeCharId,
  899. value: buffer,
  900. writeType: currentType
  901. });
  902. this.log(`已发送${description}: ${hex}`);
  903. } catch (e) {
  904. console.error(`[BLE Send Error] Type=${currentType}:`, e);
  905. // 不管什么错误,尝试切换 writeType 重试一次
  906. const retryType = currentType === 'write' ? 'writeNoResponse' : 'write';
  907. this.log(`发送失败 (${e.errCode || e.errMsg || e}),尝试切换为 ${retryType} 重试...`);
  908. try {
  909. await promisifyUniApi('writeBLECharacteristicValue', {
  910. deviceId: this.deviceId,
  911. serviceId: this.writeServiceId,
  912. characteristicId: this.writeCharId,
  913. value: buffer,
  914. writeType: retryType
  915. });
  916. this.writeType = retryType; // 如果重试成功,更新默认类型
  917. this.log(`重试成功 (${retryType}): ${hex}`);
  918. } catch (retryErr) {
  919. console.error(`[BLE Retry Error] Type=${retryType}:`, retryErr);
  920. // 还是失败,尝试不传 writeType (让系统自动判断)
  921. this.log(`二次重试失败,尝试不传 writeType...`);
  922. try {
  923. await promisifyUniApi('writeBLECharacteristicValue', {
  924. deviceId: this.deviceId,
  925. serviceId: this.writeServiceId,
  926. characteristicId: this.writeCharId,
  927. value: buffer
  928. // 不传 writeType
  929. });
  930. this.log(`三次重试成功 (Auto): ${hex}`);
  931. } catch (finalErr) {
  932. console.error(`[BLE Final Error]:`, finalErr);
  933. this.log(`发送最终失败: ${finalErr.errMsg || finalErr}`);
  934. }
  935. }
  936. }
  937. },
  938. // 手动开始血压测量
  939. /**
  940. * 用户点击后主动发起血压测量请求
  941. * - 要求蓝牙已经连接且写入特征可用
  942. * - 发送新协议开始测量指令 (FA 05)
  943. */
  944. startBpMeasurement() {
  945. if (!this.writeCharId) {
  946. uni.showToast({ title: '蓝牙未连接', icon: 'none' });
  947. return;
  948. }
  949. this.userRequestedMeasure = true;
  950. this.bpMeasureStarted = false;
  951. this.bpErrorMsg = '';
  952. this.bpProcessStatus = 'handshake';
  953. this.bpProcessStep = '正在请求开始测量...';
  954. // 新协议指令 (V2/V5协议)
  955. const bufferV2D2 = buildV2Command(0xd2); // D2为V5协议的开始测量指令
  956. // 旧协议指令
  957. const buffer = new Uint8Array([0xfa, 0x05, 0x00, 0x00, 0x00, 0x00, 0x05, 0x04]).buffer;
  958. // 连续发送几次以确保设备收到,旧设备握手+气泵启动可能需要较长时间,增加重试次数
  959. let retryCount = 0;
  960. const maxRetries = 8; // 增加到8次,给予约8秒的等待时间
  961. const sendTimer = setInterval(() => {
  962. if (this.bpMeasureStarted || retryCount >= maxRetries || !this.userRequestedMeasure) {
  963. clearInterval(sendTimer);
  964. if (retryCount >= maxRetries && !this.bpMeasureStarted) {
  965. this.log('设备未响应测量指令');
  966. this.bpProcessStep = '设备未响应,请重试';
  967. uni.showToast({ title: '设备未响应,请重试', icon: 'none' });
  968. }
  969. return;
  970. }
  971. this.sendData(buffer, '开始测量(FA 05)');
  972. // 延迟一点发送兼容指令
  973. setTimeout(() => {
  974. if (!this.bpMeasureStarted && this.userRequestedMeasure) {
  975. this.sendData(bufferV2D2, '开始测量(D2)');
  976. }
  977. }, 200);
  978. retryCount++;
  979. }, 1000);
  980. // 立即发送第一次
  981. this.sendData(buffer, '开始测量(FA 05)');
  982. setTimeout(() => {
  983. this.sendData(bufferV2D2, '开始测量(D2)');
  984. }, 200);
  985. retryCount++;
  986. },
  987. // 进入血检模式
  988. enterBloodTest() {
  989. if (!this.writeCharId) {
  990. uni.showToast({ title: '蓝牙未连接', icon: 'none' });
  991. return;
  992. }
  993. this.log('请求进入血检模式...');
  994. const bufferV2D3 = buildV2Command(0xd3); // D3为进入血检界面指令
  995. this.sendData(bufferV2D3, '进入血检(D3)');
  996. },
  997. async connectDevice(isAutoConnect = false) {
  998. if (!this.selectedDeviceId) {
  999. uni.showToast({ title: '请先选择设备', icon: 'none' });
  1000. return;
  1001. }
  1002. this.userRequestedMeasure = false; // 连接时重置状态,等待用户点击开始测量
  1003. this.connecting = true;
  1004. this.log('正在连接 ' + this.selectedDeviceName + '...');
  1005. this.statusText = '连接中...';
  1006. try {
  1007. await promisifyUniApi('createBLEConnection', {
  1008. deviceId: this.selectedDeviceId,
  1009. timeout: 10000
  1010. });
  1011. this.deviceId = this.selectedDeviceId;
  1012. this.connected = true;
  1013. this.statusText = '已连接';
  1014. this.log('连接成功,正在发现服务...');
  1015. await new Promise((r) => setTimeout(r, 600));
  1016. const { services } = await promisifyUniApi('getBLEDeviceServices', {
  1017. deviceId: this.deviceId
  1018. });
  1019. if (!services || services.length === 0) {
  1020. this.log('未发现服务');
  1021. this.connecting = false;
  1022. return;
  1023. }
  1024. this.log('发现服务: ' + services.length + ' 个');
  1025. services.forEach((s, i) => {
  1026. console.log(`[Service ${i}] UUID: ${s.uuid}, IsPrimary: ${s.isPrimary}`);
  1027. this.log(`S${i}: ${s.uuid.slice(4, 8)}`);
  1028. });
  1029. // 优先尝试 FFF0 (回到最初的起点)
  1030. let svc = services.find((s) => s.uuid.toLowerCase().includes('fff0'));
  1031. if (!svc) {
  1032. // 如果没有 FFF0,尝试 FF10
  1033. svc = services.find((s) => s.uuid.toLowerCase().includes('ff10'));
  1034. }
  1035. if (svc) {
  1036. this.serviceId = svc.uuid;
  1037. this.log('使用服务: ' + this.serviceId.slice(4, 8));
  1038. // 如果是 FF10,特征值可能也是 FF11/FF12,或者其他
  1039. // 需要重新获取该服务的特征值
  1040. } else {
  1041. this.log('错误: 未找到有效服务 (FF10/FFF0)');
  1042. this.connecting = false;
  1043. return;
  1044. }
  1045. const { characteristics } = await promisifyUniApi('getBLEDeviceCharacteristics', {
  1046. deviceId: this.deviceId,
  1047. serviceId: this.serviceId
  1048. });
  1049. // 打印所有特征值的详细属性,便于排查
  1050. this.log('发现特征值: ' + characteristics.length + ' 个');
  1051. characteristics.forEach((c, index) => {
  1052. const props = [];
  1053. if (c.properties.read) props.push('Read');
  1054. if (c.properties.write) props.push('Write');
  1055. if (c.properties.writeNoResponse) props.push('WriteNoResp');
  1056. if (c.properties.notify) props.push('Notify');
  1057. if (c.properties.indicate) props.push('Indicate');
  1058. console.log(`[Char ${index}] UUID: ${c.uuid}, Props: ${props.join('|')}`);
  1059. this.log(`C${index}: ${c.uuid.slice(4, 8)} [${props.join('|')}]`);
  1060. });
  1061. // 1. 查找接收特征 (Notify/Indicate)
  1062. // 尝试匹配 FFF1, FF11 或任何 Notify
  1063. let notifyChar = characteristics.find(
  1064. (c) => (c.uuid.toLowerCase().includes('fff1') || c.uuid.toLowerCase().includes('ff11')) && (c.properties.notify || c.properties.indicate)
  1065. );
  1066. // 如果没找到,尝试只匹配 UUID
  1067. if (!notifyChar) {
  1068. notifyChar = characteristics.find((c) => c.uuid.toLowerCase().includes('fff1') || c.uuid.toLowerCase().includes('ff11'));
  1069. }
  1070. // 如果还是没找到,回退到任意具备 Notify/Indicate 属性的特征
  1071. if (!notifyChar) {
  1072. this.log('未找到标准接收特征,尝试回退...');
  1073. notifyChar = characteristics.find((c) => c.properties.notify || c.properties.indicate);
  1074. }
  1075. if (notifyChar) {
  1076. this.notifyCharId = notifyChar.uuid;
  1077. this.notifyServiceId = this.serviceId;
  1078. } else {
  1079. this.log('错误: 未找到任何可接收数据的特征值');
  1080. this.connecting = false;
  1081. return;
  1082. }
  1083. // 2. 查找发送特征 (Write/WriteNoResponse)
  1084. // 尝试匹配 FFF2, FF12 或任何 Write
  1085. let writeChar = characteristics.find(
  1086. (c) => (c.uuid.toLowerCase().includes('fff2') || c.uuid.toLowerCase().includes('ff12')) && (c.properties.write || c.properties.writeNoResponse)
  1087. );
  1088. // 如果没找到,回退到任意具备 Write/WriteNoResponse 属性的特征
  1089. if (!writeChar) {
  1090. this.log('未找到标准发送特征,尝试回退...');
  1091. writeChar = characteristics.find((c) => c.properties.write || c.properties.writeNoResponse);
  1092. }
  1093. if (writeChar) {
  1094. this.writeServiceId = this.serviceId;
  1095. this.writeCharId = writeChar.uuid;
  1096. // 确定写入类型:优先使用 write (带响应),如果不支持则用 writeNoResponse
  1097. // 注意:有些设备虽然标明 write,但实际需要 writeNoResponse,反之亦然
  1098. // 我们这里记录首选类型,在 sendData 中会实现自动降级重试
  1099. if (writeChar.properties.write) {
  1100. this.writeType = 'write';
  1101. } else if (writeChar.properties.writeNoResponse) {
  1102. this.writeType = 'writeNoResponse';
  1103. } else {
  1104. this.writeType = 'write'; // 默认
  1105. }
  1106. this.log(`发送特征就绪 (Type: ${this.writeType})`);
  1107. } else {
  1108. this.log('警告: 未找到可写特征,无法发送指令');
  1109. }
  1110. this.log(`最终配置: Notify=${this.notifyCharId.slice(4, 8)}, Write=${this.writeCharId ? this.writeCharId.slice(4, 8) : '无'}`);
  1111. await promisifyUniApi('notifyBLECharacteristicValueChange', {
  1112. deviceId: this.deviceId,
  1113. serviceId: this.notifyServiceId,
  1114. characteristicId: this.notifyCharId,
  1115. state: true
  1116. });
  1117. this.log('已开启数据通知');
  1118. // 不再自动发送任何握手指令,等待用户点击开始测量
  1119. if (this.writeCharId) {
  1120. this.log('连接成功,等待用户操作');
  1121. this.bpProcessStatus = 'connected';
  1122. this.bpProcessStep = '已连接,请点击开始测量';
  1123. }
  1124. this.log('已开启数据通知');
  1125. uni.onBLECharacteristicValueChange((res) => {
  1126. if (res.deviceId !== this.deviceId) return;
  1127. const buf = res.value;
  1128. const arr = new Uint8Array(buf);
  1129. const hex = Array.from(arr)
  1130. .map((b) => ('0' + b.toString(16)).slice(-2))
  1131. .join(' ');
  1132. console.log('[BLE Recv Raw] Hex:', hex);
  1133. console.log('[BLE Recv Raw] Bytes:', arr);
  1134. this.log('收到数据: ' + hex);
  1135. // 1. 尝试解析血压协议 (FD FD)
  1136. // 尝试解析 V2 格式 (FD FD Len Cmd ... CS 0D 0A)
  1137. // V2格式特点:第2字节(索引2)是Len,最后一个字节是0A,倒数第二个是0D,倒数第三个是CS
  1138. let isV2 = false;
  1139. let v2Cmd = null;
  1140. let v2Data = null;
  1141. if (arr.length >= 6 && arr[0] === 0xfd && arr[1] === 0xfd) {
  1142. const len = arr[2];
  1143. if (arr.length >= 2 + 1 + len + 2) {
  1144. const content = arr.slice(3, 3 + len);
  1145. v2Cmd = content[0];
  1146. v2Data = content.slice(1, content.length - 1);
  1147. const checksum = content[content.length - 1];
  1148. let sum = v2Cmd;
  1149. for (let i = 0; i < v2Data.length; i++) sum += v2Data[i];
  1150. if ((sum & 0xff) === checksum) {
  1151. isV2 = true;
  1152. }
  1153. }
  1154. }
  1155. // 处理旧协议 V2 逻辑
  1156. if (isV2) {
  1157. // 1. 握手: A0 -> D1
  1158. if (v2Cmd === 0xa0) {
  1159. this.log('收到旧协议广播 (A0),发送连接请求 (D1)...');
  1160. this.bpProcessStatus = 'handshake';
  1161. this.bpProcessStep = '收到广播,请求连接 (D1)...';
  1162. this.sendData(buildV2Command(0xd1), '连接请求(D1)');
  1163. return;
  1164. }
  1165. // 1.1 握手确认: D1 -> D1 (设备回显)
  1166. if (v2Cmd === 0xd1) {
  1167. this.log('收到 D1 回显,等待 AF...');
  1168. this.bpProcessStatus = 'handshake';
  1169. this.bpProcessStep = '设备已确认请求,等待连接成功(AF)...';
  1170. return;
  1171. }
  1172. // 2. 握手成功: AF
  1173. if (v2Cmd === 0xaf) {
  1174. this.log('收到连接建立成功信号 (AF)');
  1175. this.bpProcessStatus = 'handshake';
  1176. if (this.userRequestedMeasure) {
  1177. this.bpProcessStep = '连接成功,发送开始测量指令 (D2)...';
  1178. this.sendData(buildV2Command(0xd2), '开始测量(D2)');
  1179. } else {
  1180. this.bpProcessStep = '设备已就绪,等待用户点击开始测量';
  1181. this.log('设备已就绪,等待用户点击开始测量,暂不发送D2');
  1182. }
  1183. return;
  1184. }
  1185. // 2.1 血压测量确认: A1 (设备回复)
  1186. if (v2Cmd === 0xa1) {
  1187. this.log('设备已确认测量请求 (A1)');
  1188. this.bpProcessStatus = 'measuring';
  1189. this.bpProcessStep = '设备已就绪,等待加压...';
  1190. this.bpMeasureStarted = true;
  1191. return;
  1192. }
  1193. // 2.2 进入血检确认: D4 (设备回复)
  1194. if (v2Cmd === 0xd4) {
  1195. this.log('设备已进入血检界面 (D4)');
  1196. this.bpProcessStatus = 'idle';
  1197. this.bpProcessStep = '请插入试纸...';
  1198. uni.showToast({ title: '请插入试纸', icon: 'none' });
  1199. return;
  1200. }
  1201. // 5. 血压测量过程 (A2)
  1202. if (v2Cmd === 0xa2) {
  1203. this.bpProcessStatus = 'measuring';
  1204. if (v2Data.length >= 2) {
  1205. const pressure = v2Data[0] * 256 + v2Data[1];
  1206. this.bpCurrentPressure = pressure;
  1207. this.bpMeasureStarted = true;
  1208. this.bpProcessStep = `测量中... ${pressure} mmHg`;
  1209. }
  1210. return;
  1211. }
  1212. // 测试结果 (A3)
  1213. if (v2Cmd === 0xa3) {
  1214. // [0xFD,0xFD, 0x07, 0xA3, SYS, DIA, 单位, PUL, IHB, Checksum, 0X0D, 0x0A]
  1215. // v2Data: [SYS, DIA, 单位, PUL, IHB]
  1216. if (v2Data.length >= 5) {
  1217. const systolic = v2Data[0];
  1218. const diastolic = v2Data[1];
  1219. const unitByte = v2Data[2]; // 00=mmHg, 01=kPa (通常是00)
  1220. const pulse = v2Data[3];
  1221. const ihb = v2Data[4];
  1222. this.userRequestedMeasure = false;
  1223. this.bpMeasureStarted = false; // 测量结束,重置开始标志
  1224. if (systolic > 0 && diastolic > 0) {
  1225. this.bpCurrentPressure = null;
  1226. this.bpErrorMsg = '';
  1227. this.bpProcessStatus = 'result';
  1228. this.bpProcessStep = '测量完成';
  1229. this.lastBloodPressure = {
  1230. systolic,
  1231. diastolic,
  1232. pulse,
  1233. ihb: ihb === 0x01 ? 1 : 0,
  1234. time: Date.now()
  1235. };
  1236. this.$forceUpdate();
  1237. this.log(`血压(V2): ${systolic}/${diastolic} mmHg 脉率:${pulse}`);
  1238. } else {
  1239. this.bpCurrentPressure = null;
  1240. this.bpErrorMsg = '测量结果异常 (数据为0)';
  1241. this.bpProcessStatus = 'error';
  1242. this.bpProcessStep = '测量失败';
  1243. this.log('血压计错误(V2): 测量结果为0');
  1244. uni.showToast({ title: '测量错误请重试', icon: 'none', duration: 3000 });
  1245. }
  1246. }
  1247. return;
  1248. }
  1249. // 6. 血压测量错误 (A4)
  1250. if (v2Cmd === 0xa4) {
  1251. const errCode = v2Data[0] || 0;
  1252. this.userRequestedMeasure = false; // 测量结束,重置状态
  1253. this.bpMeasureStarted = false; // 测量结束,重置开始标志
  1254. this.bpErrorMsg = '测量错误请重试';
  1255. this.bpProcessStatus = 'error';
  1256. this.bpProcessStep = '测量失败';
  1257. this.log(`血压计测量错误(V2): 错误码 ${errCode.toString(16).toUpperCase()}`);
  1258. uni.showToast({ title: '测量错误请重试', icon: 'none', duration: 3000 });
  1259. return;
  1260. }
  1261. // 7. 血糖测量开始 (BB)
  1262. if (v2Cmd === 0xbb) {
  1263. this.log('收到血糖测量开始指令 (BB),回复 D9...');
  1264. this.glucoseStatus = '请滴入样本...';
  1265. this.glucoseErrorMsg = '';
  1266. this.sendData(buildV2Command(0xd9), '回复血糖开始(D9)');
  1267. return;
  1268. }
  1269. // 8. 血糖测量结果 (B2)
  1270. if (v2Cmd === 0xb2) {
  1271. if (v2Data.length >= 3) {
  1272. const valH = v2Data[0];
  1273. const valL = v2Data[1];
  1274. const unitByte = v2Data[2];
  1275. const value = (valH << 8) | valL;
  1276. let displayVal = '0.0';
  1277. if (valH === 0xff && valL === 0xff) {
  1278. displayVal = 'HIGH';
  1279. } else if (valH === 0x00 && valL === 0x00) {
  1280. displayVal = 'LOW';
  1281. } else {
  1282. displayVal = (value / 10).toFixed(1);
  1283. }
  1284. this.lastGlucose = {
  1285. value: displayVal,
  1286. unit: unitByte === 0x01 ? 'mg/dL' : 'mmol/L',
  1287. time: Date.now()
  1288. };
  1289. this.glucoseStatus = '测量完成';
  1290. this.log(`血糖结果(V2): ${displayVal} ${this.lastGlucose.unit}`);
  1291. this.sendData(buildV2Command(0xda), '回复血糖结果(DA)');
  1292. }
  1293. return;
  1294. }
  1295. // 9. 血糖报错 (B3)
  1296. if (v2Cmd === 0xb3) {
  1297. const errCode = v2Data[0];
  1298. this.glucoseStatus = '';
  1299. this.glucoseErrorMsg = `测量错误: 错误码 ${errCode.toString(16).toUpperCase()}`;
  1300. this.log(`血糖报错(V2): ${errCode.toString(16).toUpperCase()}`);
  1301. this.sendData(buildV2Command(0xdb), '回复血糖报错(DB)');
  1302. return;
  1303. }
  1304. // 10. 尿酸测量开始 (CC)
  1305. if (v2Cmd === 0xcc) {
  1306. this.log('收到尿酸测量开始指令 (CC),回复 DC...');
  1307. this.uricAcidStatus = '请滴入样本...';
  1308. this.uricAcidErrorMsg = '';
  1309. this.sendData(buildV2Command(0xdc), '回复尿酸开始(DC)');
  1310. return;
  1311. }
  1312. // 11. 尿酸测量结果 (C2)
  1313. if (v2Cmd === 0xc2) {
  1314. if (v2Data.length >= 3) {
  1315. const valH = v2Data[0];
  1316. const valL = v2Data[1];
  1317. const unitByte = v2Data[2];
  1318. const value = (valH << 8) | valL;
  1319. let displayVal = '0';
  1320. if (valH === 0xff && valL === 0xff) {
  1321. displayVal = 'HIGH';
  1322. } else if (valH === 0x00 && valL === 0x00) {
  1323. displayVal = 'LOW';
  1324. } else {
  1325. displayVal = value.toString();
  1326. }
  1327. this.lastUricAcid = {
  1328. value: displayVal,
  1329. unit: unitByte === 0x01 ? 'mg/dL' : 'umol/L',
  1330. time: Date.now()
  1331. };
  1332. this.uricAcidStatus = '测量完成';
  1333. this.log(`尿酸结果(V2): ${displayVal} ${this.lastUricAcid.unit}`);
  1334. this.sendData(buildV2Command(0xdd), '回复尿酸结果(DD)');
  1335. }
  1336. return;
  1337. }
  1338. // 12. 尿酸报错 (C3)
  1339. if (v2Cmd === 0xc3) {
  1340. const errCode = v2Data[0];
  1341. this.uricAcidStatus = '';
  1342. this.uricAcidErrorMsg = `测量错误: 错误码 ${errCode.toString(16).toUpperCase()}`;
  1343. this.log(`尿酸报错(V2): ${errCode.toString(16).toUpperCase()}`);
  1344. this.sendData(buildV2Command(0xde), '回复尿酸报错(DE)');
  1345. return;
  1346. }
  1347. }
  1348. // 新协议解析 (FD FD ...)
  1349. const bpPacket = parseBpPacket(arr);
  1350. if (bpPacket) {
  1351. const { cmd, data } = bpPacket;
  1352. // 兼容:旧协议的 A0 广播 (FD FD 02 A0 A0 0D 0A) 或 D1 (FD FD 02 D1 D1 0D 0A)
  1353. // 如果 isV2 为 true 且是 A0,已经在上面处理过了。这里作为备用。
  1354. if (cmd === 0x02 && data.length >= 2 && data[0] === 0xa0 && data[1] === 0xa0) {
  1355. if (!isV2) {
  1356. this.log('收到旧协议广播 (A0)[fallback],发送 D1');
  1357. this.sendData(buildV2Command(0xd1), '连接请求(D1)');
  1358. }
  1359. return;
  1360. }
  1361. // A4 广播 (等待连接)
  1362. if (cmd === CMD_BP_BROADCAST_A4) {
  1363. if (data.length >= 3) {
  1364. this.bpWaitUser = data[2]; // 用户号在第3个字节(索引2)
  1365. }
  1366. this.log('收到血压计唤醒请求 (A4)');
  1367. // 如果用户已点击开始测量,持续回复 FA 05 直到设备确认并停止发送 A4
  1368. if (this.userRequestedMeasure) {
  1369. this.bpProcessStep = '设备唤醒中,正在同步指令...';
  1370. const buffer = buildBpCommand(CMD_BP_CONNECT_FA, [0x05]);
  1371. this.sendData(buffer, '响应A4(FA 05)');
  1372. } else {
  1373. this.bpProcessStep = '设备就绪,等待用户开始测量';
  1374. }
  1375. return;
  1376. }
  1377. // 06 确认开始测量
  1378. if (cmd === CMD_BP_CONFIRM_06 || (cmd === CMD_BP_CONNECT_FA && data[0] === 0x06)) {
  1379. this.log('设备已确认测量请求 (06)');
  1380. this.bpProcessStatus = 'measuring';
  1381. this.bpProcessStep = '设备已就绪,等待加压...';
  1382. this.bpMeasureStarted = true;
  1383. return;
  1384. }
  1385. // FB 测量中压力
  1386. if (cmd === CMD_BP_PRESSURE_FB) {
  1387. this.bpProcessStatus = 'measuring';
  1388. if (data.length >= 2) {
  1389. const pressure = data[0] * 256 + data[1];
  1390. this.bpCurrentPressure = pressure;
  1391. this.bpMeasureStarted = true;
  1392. this.bpProcessStep = `测量中... ${pressure} mmHg`;
  1393. }
  1394. return;
  1395. }
  1396. // FC 测试结果
  1397. if (cmd === CMD_BP_RESULT_FC) {
  1398. // data: [SYS, DIA, PUL, IHB]
  1399. if (data.length >= 4) {
  1400. const systolic = data[0];
  1401. const diastolic = data[1];
  1402. const pulse = data[2];
  1403. const ihb = data[3];
  1404. this.userRequestedMeasure = false;
  1405. this.bpMeasureStarted = false; // 测量结束,重置开始标志
  1406. if (systolic > 0 && diastolic > 0) {
  1407. this.bpCurrentPressure = null;
  1408. this.bpErrorMsg = '';
  1409. this.bpProcessStatus = 'result';
  1410. this.bpProcessStep = '测量完成';
  1411. this.lastBloodPressure = {
  1412. systolic,
  1413. diastolic,
  1414. pulse,
  1415. ihb: ihb === 0x01 ? 1 : 0,
  1416. time: Date.now()
  1417. };
  1418. this.$forceUpdate();
  1419. this.log(`血压: ${systolic}/${diastolic} mmHg`);
  1420. // 按照文档要求回复 FA 60 确认指令,防止设备重复发送
  1421. const replyBuffer = buildBpCommand(CMD_BP_CONNECT_FA, [0x60]);
  1422. this.sendData(replyBuffer, '确认结果(FA 60)');
  1423. } else {
  1424. this.bpCurrentPressure = null;
  1425. this.bpErrorMsg = '测量结果异常 (数据为0)';
  1426. this.bpProcessStatus = 'error';
  1427. this.bpProcessStep = '测量失败';
  1428. this.log('血压计错误: 测量结果为0');
  1429. uni.showToast({ title: '测量错误请重试', icon: 'none', duration: 3000 });
  1430. }
  1431. }
  1432. return;
  1433. }
  1434. // FD 测量错误
  1435. if (cmd === CMD_BP_ERROR_FD) {
  1436. const errCode = data[0] || 0;
  1437. this.userRequestedMeasure = false; // 测量结束,重置状态
  1438. this.bpMeasureStarted = false; // 测量结束,重置开始标志
  1439. this.bpCurrentPressure = null;
  1440. this.bpErrorMsg = '测量错误请重试';
  1441. this.bpProcessStatus = 'error';
  1442. this.bpProcessStep = '测量失败';
  1443. this.log(`血压计测量错误: 错误码 ${errCode.toString(16).toUpperCase()}`);
  1444. uni.showToast({ title: '测量错误请重试', icon: 'none', duration: 3000 });
  1445. return;
  1446. }
  1447. return;
  1448. }
  1449. // 2. 尝试解析血糖/尿酸协议 (A5 A5)
  1450. const glucoseRes = parseGlucosePacket(buf);
  1451. if (glucoseRes) {
  1452. if (glucoseRes.type === 'glucose_start') {
  1453. this.glucoseStatus = '请滴入样本...';
  1454. this.glucoseErrorMsg = '';
  1455. this.log('血糖测量开始');
  1456. } else if (glucoseRes.type === 'uric_start') {
  1457. this.uricAcidStatus = '请滴入样本...';
  1458. this.uricAcidErrorMsg = '';
  1459. this.log('尿酸测量开始');
  1460. } else if (glucoseRes.type === 'glucose_result') {
  1461. this.lastGlucose = {
  1462. value: glucoseRes.value,
  1463. unit: glucoseRes.unit,
  1464. time: glucoseRes.time
  1465. };
  1466. this.glucoseStatus = '测量完成';
  1467. this.log(`血糖结果: ${glucoseRes.value} ${glucoseRes.unit}`);
  1468. } else if (glucoseRes.type === 'uric_result') {
  1469. this.lastUricAcid = {
  1470. value: glucoseRes.value,
  1471. unit: glucoseRes.unit,
  1472. time: glucoseRes.time
  1473. };
  1474. this.uricAcidStatus = '测量完成';
  1475. this.log(`尿酸结果: ${glucoseRes.value} ${glucoseRes.unit}`);
  1476. } else if (glucoseRes.type === 'glucose_error') {
  1477. this.glucoseErrorMsg = glucoseRes.msg;
  1478. this.glucoseStatus = '';
  1479. this.log(glucoseRes.msg);
  1480. } else if (glucoseRes.type === 'uric_error') {
  1481. this.uricAcidErrorMsg = glucoseRes.msg;
  1482. this.uricAcidStatus = '';
  1483. this.log(glucoseRes.msg);
  1484. }
  1485. if (glucoseRes.replyCmd) {
  1486. this.sendData(glucoseRes.replyCmd, '回复确认');
  1487. }
  1488. return;
  1489. }
  1490. });
  1491. uni.showToast({ title: '连接成功', icon: 'success' });
  1492. if (!this.boundDevice) {
  1493. const deviceInfo = {
  1494. deviceId: this.selectedDeviceId,
  1495. name: this.selectedDeviceName
  1496. };
  1497. this.boundDevice = deviceInfo;
  1498. uni.setStorageSync('bound_health_device', JSON.stringify(deviceInfo));
  1499. this.log('已绑定设备: ' + deviceInfo.name);
  1500. }
  1501. } catch (e) {
  1502. this.connected = false;
  1503. if (!this.boundDevice) {
  1504. this.deviceId = '';
  1505. }
  1506. this.statusText = '未连接';
  1507. const errMsg = e.errMsg || e.message || String(e);
  1508. this.log('连接失败: ' + errMsg);
  1509. uni.showToast({ title: '连接失败', icon: 'none' });
  1510. if (isAutoConnect === true && this.boundDevice) {
  1511. this.log('直接连接失败,尝试后台扫描...');
  1512. this.startBackgroundScanForBoundDevice();
  1513. }
  1514. }
  1515. this.connecting = false;
  1516. },
  1517. async disconnect() {
  1518. this.disconnecting = true;
  1519. this.log('断开连接...');
  1520. await this.closeBLE();
  1521. this.connected = false;
  1522. this.deviceId = '';
  1523. this.serviceId = '';
  1524. this.characteristicId = '';
  1525. this.notifyServiceId = '';
  1526. this.notifyCharId = '';
  1527. this.writeServiceId = '';
  1528. this.writeCharId = '';
  1529. this.lastGlucose = { value: null, unit: 'mmol/L', time: null };
  1530. this.glucoseStatus = '';
  1531. this.glucoseErrorMsg = '';
  1532. this.lastUricAcid = { value: null, unit: 'umol/L', time: null };
  1533. this.uricAcidStatus = '';
  1534. this.uricAcidErrorMsg = '';
  1535. this.lastBloodPressure = { systolic: null, diastolic: null, pulse: null, ihb: null, time: null };
  1536. this.bpMeasureStarted = false;
  1537. this.userRequestedMeasure = false;
  1538. this.bpCurrentPressure = null;
  1539. this.bpErrorMsg = '';
  1540. this.statusText = '未连接';
  1541. this.disconnecting = false;
  1542. this.log('已断开');
  1543. },
  1544. async closeBLE() {
  1545. try {
  1546. if (this.deviceId) {
  1547. await promisifyUniApi('closeBLEConnection', { deviceId: this.deviceId });
  1548. }
  1549. } catch (e) {}
  1550. try {
  1551. await promisifyUniApi('closeBluetoothAdapter');
  1552. } catch (e) {}
  1553. }
  1554. }
  1555. };
  1556. </script>
  1557. <style lang="scss" scoped>
  1558. .page {
  1559. min-height: 100vh;
  1560. background: #ffffff;
  1561. }
  1562. .footer-txt{
  1563. position: absolute;
  1564. bottom: 40rpx;
  1565. display: flex;
  1566. align-items: center;
  1567. justify-content: center;
  1568. flex-direction: column;
  1569. .pre{
  1570. width: 450rpx;
  1571. height: 56rpx;
  1572. line-height: 56rpx;
  1573. text-align: center;
  1574. background: #F0FDF4;
  1575. border-radius: 28rpx 28rpx 28rpx 28rpx;
  1576. border: 1rpx solid rgba(34,197,94,0.1);
  1577. font-family: PingFang SC, PingFang SC;
  1578. font-weight: 400;
  1579. font-size: 24rpx;
  1580. color: #22C55E;
  1581. }
  1582. .time{
  1583. margin-top: 16rpx;
  1584. font-family: PingFang SC, PingFang SC;
  1585. font-weight: 400;
  1586. font-size: 24rpx;
  1587. color: #999999;
  1588. line-height: 40rpx;
  1589. text-align: center;
  1590. }
  1591. }
  1592. .nav-bar {
  1593. position: sticky;
  1594. top: 0;
  1595. z-index: 10;
  1596. display: flex;
  1597. align-items: center;
  1598. justify-content: center;
  1599. justify-content: space-between;
  1600. // height: 88rpx;
  1601. margin: 0 24rpx;
  1602. background: #fff;
  1603. }
  1604. .nav-title {
  1605. font-size: 34rpx;
  1606. font-weight: 600;
  1607. color: #333;
  1608. }
  1609. .nav-back {
  1610. // position: absolute;
  1611. // left: 24rpx;
  1612. // padding: 10rpx 0;
  1613. }
  1614. .back-text {
  1615. width: 64rpx;
  1616. height: 64rpx;
  1617. }
  1618. .next-text {
  1619. width: 48rpx;
  1620. height: 48rpx;
  1621. }
  1622. .content {
  1623. padding: 24rpx;
  1624. padding-bottom: 60rpx;
  1625. padding-top: 40rpx;
  1626. }
  1627. /* ============ 截图风格UI ============ */
  1628. .tabs {
  1629. display: flex;
  1630. gap: 18rpx;
  1631. margin-bottom: 50rpx;
  1632. }
  1633. .tab {
  1634. flex: 1;
  1635. height: 72rpx;
  1636. line-height: 72rpx;
  1637. text-align: center;
  1638. border-radius: 36rpx;
  1639. background: #F5F7FA;
  1640. font-family: PingFang SC, PingFang SC;
  1641. font-weight: 400;
  1642. font-size: 28rpx;
  1643. color: #67686F;
  1644. }
  1645. .tab.active {
  1646. background: #FF7700;
  1647. color: #fff;
  1648. font-weight: 600;
  1649. }
  1650. .meter-wrap {
  1651. display: flex;
  1652. justify-content: center;
  1653. margin-top: 12rpx;
  1654. }
  1655. .meter-ring {
  1656. position: relative;
  1657. width: 510rpx;
  1658. height: 510rpx;
  1659. border-radius: 50%;
  1660. background: conic-gradient(var(--arc, #EAEAEA) var(--p, 0%), var(--track, #EAEAEA) 0);
  1661. display: flex;
  1662. align-items: center;
  1663. justify-content: center;
  1664. }
  1665. .meter-ring::after {
  1666. content: "";
  1667. position: absolute;
  1668. inset: 24rpx;
  1669. background: #ffffff;
  1670. border-radius: 50%;
  1671. }
  1672. .meter-center {
  1673. position: absolute;
  1674. z-index: 1;
  1675. left: 0;
  1676. right: 0;
  1677. top: 0;
  1678. bottom: 0;
  1679. display: flex;
  1680. flex-direction: column;
  1681. align-items: center;
  1682. justify-content: center;
  1683. text-align: center;
  1684. }
  1685. .ready-view {
  1686. display: flex;
  1687. flex-direction: column;
  1688. align-items: center;
  1689. justify-content: center;
  1690. }
  1691. .ready-checkimg{
  1692. width: 104rpx;
  1693. height: 104rpx;
  1694. }
  1695. .ready-check {
  1696. width: 104rpx;
  1697. height: 104rpx;
  1698. border-radius: 50%;
  1699. background: #FF7700;
  1700. color: #fff;
  1701. display: flex;
  1702. align-items: center;
  1703. justify-content: center;
  1704. font-size: 56rpx;
  1705. font-weight: 700;
  1706. margin-bottom: 18rpx;
  1707. }
  1708. .disconnected-check {
  1709. background: #EAEAEA;
  1710. color: #999;
  1711. }
  1712. .ready-text {
  1713. font-size: 28rpx;
  1714. color: #666;
  1715. font-weight: 500;
  1716. }
  1717. .bp-value {
  1718. font-size: 76rpx;
  1719. font-weight: 700;
  1720. color: #111;
  1721. line-height: 88rpx;
  1722. }
  1723. .bp-value-unit {
  1724. font-size: 28rpx;
  1725. font-weight: 500;
  1726. color: #999;
  1727. margin-left: 10rpx;
  1728. vertical-align: middle;
  1729. }
  1730. .measuring-sub,
  1731. .result-sub {
  1732. margin-top: 10rpx;
  1733. font-family: PingFang SC, PingFang SC;
  1734. font-weight: 400;
  1735. font-size: 28rpx;
  1736. color: #757575;
  1737. }
  1738. .error-num {
  1739. color: #999;
  1740. }
  1741. .error-sub {
  1742. color: #E64340;
  1743. }
  1744. .tip-box {
  1745. margin: 40rpx 0 0 0;
  1746. padding: 20rpx 30rpx;
  1747. background: #fff6f1;
  1748. border-radius: 16rpx;
  1749. display: flex;
  1750. align-items: flex-start;
  1751. gap: 14rpx;
  1752. background: rgba(255,119,0,0.05);
  1753. border-radius: 16rpx 16rpx 16rpx 16rpx;
  1754. border: 2rpx solid rgba(255,119,0,0.1);
  1755. }
  1756. .tip-icon {
  1757. width: 34rpx;
  1758. height: 34rpx;
  1759. border-radius: 50%;
  1760. background: #FF7700;
  1761. color: #fff;
  1762. display: flex;
  1763. align-items: center;
  1764. justify-content: center;
  1765. font-size: 22rpx;
  1766. font-weight: 700;
  1767. flex-shrink: 0;
  1768. line-height: 34rpx;
  1769. }
  1770. .tip-text {
  1771. font-family: PingFang SC, PingFang SC;
  1772. font-weight: 400;
  1773. font-size: 24rpx;
  1774. color: #67686F;
  1775. line-height: 36rpx;
  1776. }
  1777. .primary-btn {
  1778. margin-top: 26rpx;
  1779. height: 96rpx;
  1780. border-radius: 18rpx;
  1781. background: linear-gradient(90deg, #F8551F 0%, #FF9501 100%);
  1782. display: flex;
  1783. align-items: center;
  1784. justify-content: center;
  1785. box-shadow: 0 12rpx 30rpx rgba(255, 119, 0, 0.25);
  1786. }
  1787. .primary-btn-disabled {
  1788. background: #ff8a3a;
  1789. box-shadow: none;
  1790. opacity: 0.55;
  1791. }
  1792. .primary-btn-text {
  1793. color: #fff;
  1794. font-size: 34rpx;
  1795. font-weight: 700;
  1796. }
  1797. .conn-card {
  1798. margin-top: 22rpx;
  1799. padding: 0 6rpx;
  1800. }
  1801. .conn-row {
  1802. display: flex;
  1803. align-items: center;
  1804. gap: 14rpx;
  1805. }
  1806. .conn-dot {
  1807. width: 12rpx;
  1808. height: 12rpx;
  1809. border-radius: 50%;
  1810. background: #07c160;
  1811. }
  1812. .conn-text {
  1813. font-size: 24rpx;
  1814. color: #666;
  1815. }
  1816. .conn-time {
  1817. margin-top: 10rpx;
  1818. font-size: 22rpx;
  1819. color: #999;
  1820. }
  1821. .sync-card {
  1822. margin-top: 26rpx;
  1823. background: #F5F7FA;
  1824. border-radius: 16rpx 16rpx 0 0;
  1825. padding:24rpx 30rpx;
  1826. display: flex;
  1827. align-items: center;
  1828. justify-content: space-between;
  1829. }
  1830. .sync-left {
  1831. display: flex;
  1832. flex-direction: column;
  1833. }
  1834. .sync-title {
  1835. font-family: PingFang SC, PingFang SC;
  1836. font-weight: 600;
  1837. font-size: 36rpx;
  1838. color: #22C55E;
  1839. line-height: 40rpx;
  1840. margin-bottom: 10rpx;
  1841. }
  1842. .sync-time {
  1843. font-family: PingFang SC, PingFang SC;
  1844. font-weight: 400;
  1845. font-size: 24rpx;
  1846. color: #999999;
  1847. line-height: 40rpx;
  1848. }
  1849. .sync-check {
  1850. width: 48rpx;
  1851. height: 48rpx;
  1852. // border-radius: 50%;
  1853. // background: #07c160;
  1854. // color: #fff;
  1855. // display: flex;
  1856. // align-items: center;
  1857. // justify-content: center;
  1858. // font-size: 28rpx;
  1859. // font-weight: 700;
  1860. }
  1861. .result-panel {
  1862. //margin-top: 18rpx;
  1863. background: #f5f7fa;
  1864. border-radius:0 0 16rpx 16rpx;
  1865. padding: 20rpx 16rpx;
  1866. }
  1867. .result-grid {
  1868. display: grid;
  1869. grid-template-columns: repeat(2, minmax(0, 1fr));
  1870. gap: 16rpx;
  1871. }
  1872. .result-card {
  1873. width: 100%;
  1874. min-width: 0;
  1875. background: #ffffff;
  1876. border-radius: 14rpx;
  1877. padding: 18rpx 16rpx;
  1878. box-sizing: border-box;
  1879. }
  1880. .result-label {
  1881. font-family: PingFang SC, PingFang SC;
  1882. font-weight: 500;
  1883. font-size: 28rpx;
  1884. color: #333333;
  1885. display: flex;
  1886. align-items: center;
  1887. justify-content: space-between;
  1888. }
  1889. .result-text{
  1890. margin-top: 16rpx;
  1891. font-family: PingFang SC, PingFang SC;
  1892. font-weight: 400;
  1893. font-size: 24rpx;
  1894. color: #757575;
  1895. }
  1896. .result-text .active{
  1897. color: #FF7700;
  1898. }
  1899. .trend-badge {
  1900. font-size: 20rpx;
  1901. color: #FF7700;
  1902. font-weight: 700;
  1903. }
  1904. .result-value {
  1905. margin-top: 8rpx;
  1906. font-family: DINPro, DINPro;
  1907. font-weight: bold;
  1908. font-size: 48rpx;
  1909. color: #333333;
  1910. }
  1911. .result-unit {
  1912. font-family: PingFang SC, PingFang SC;
  1913. font-weight: 400;
  1914. font-size: 26rpx;
  1915. color: #757575;
  1916. margin-left: 10rpx;
  1917. }
  1918. .result-chart {
  1919. margin-top: 6rpx;
  1920. height: 80rpx;
  1921. .chart-img{
  1922. width: 100%;
  1923. height: 100%;
  1924. }
  1925. }
  1926. /* ============ 旧样式(保留,给断连/设备选择区使用) ============ */
  1927. .status-card {
  1928. background: #fff;
  1929. border-radius: 24rpx;
  1930. padding: 48rpx;
  1931. margin-bottom: 32rpx;
  1932. text-align: center;
  1933. border: 2rpx solid #eee;
  1934. }
  1935. .status-card.connected {
  1936. border-color: #ff5c03;
  1937. background: #fffaf7;
  1938. }
  1939. .status-icon {
  1940. font-size: 56rpx;
  1941. color: #999;
  1942. margin-bottom: 16rpx;
  1943. }
  1944. .status-card.connected .status-icon {
  1945. color: #ff5c03;
  1946. }
  1947. .status-text {
  1948. font-size: 28rpx;
  1949. color: #666;
  1950. margin-bottom: 24rpx;
  1951. }
  1952. .bp-wait-hint {
  1953. font-size: 24rpx;
  1954. color: #999;
  1955. margin-bottom: 16rpx;
  1956. }
  1957. .bp-measure-ok {
  1958. color: #07c160;
  1959. }
  1960. .bp-pressure-live {
  1961. font-size: 28rpx;
  1962. color: #ff5c03;
  1963. font-weight: 600;
  1964. margin-bottom: 16rpx;
  1965. }
  1966. .bp-error-msg {
  1967. font-size: 24rpx;
  1968. color: #e64340;
  1969. background: #fff5f5;
  1970. padding: 16rpx;
  1971. border-radius: 12rpx;
  1972. margin-bottom: 16rpx;
  1973. line-height: 1.5;
  1974. }
  1975. .value-ihb {
  1976. display: block;
  1977. color: #e64340;
  1978. margin-top: 4rpx;
  1979. }
  1980. .measure-value {
  1981. margin: 24rpx 0 8rpx;
  1982. }
  1983. .value-label {
  1984. display: block;
  1985. font-size: 24rpx;
  1986. color: #999;
  1987. margin-bottom: 4rpx;
  1988. }
  1989. .value-num {
  1990. font-size: 72rpx;
  1991. font-weight: 700;
  1992. color: #ff5c03;
  1993. }
  1994. .value-unit {
  1995. font-size: 28rpx;
  1996. color: #999;
  1997. margin-left: 8rpx;
  1998. }
  1999. .value-extra {
  2000. display: block;
  2001. font-size: 26rpx;
  2002. color: #666;
  2003. margin-top: 8rpx;
  2004. }
  2005. .measure-time {
  2006. font-size: 24rpx;
  2007. color: #999;
  2008. }
  2009. .actions {
  2010. background: #fff;
  2011. border-radius: 24rpx;
  2012. padding: 32rpx;
  2013. margin-bottom: 24rpx;
  2014. }
  2015. .btn {
  2016. width: 100%;
  2017. height: 88rpx;
  2018. line-height: 88rpx;
  2019. border-radius: 44rpx;
  2020. font-size: 32rpx;
  2021. margin-bottom: 24rpx;
  2022. }
  2023. .btn:last-child {
  2024. margin-bottom: 0;
  2025. }
  2026. .btn-primary {
  2027. background: #ff5c03;
  2028. color: #fff;
  2029. border: none;
  2030. }
  2031. .btn-connect {
  2032. background: #07c160;
  2033. color: #fff;
  2034. border: none;
  2035. }
  2036. .btn-warn {
  2037. background: #fff;
  2038. color: #e64340;
  2039. border: 2rpx solid #e64340;
  2040. }
  2041. .device-list {
  2042. margin: 24rpx 0;
  2043. padding: 0 0 16rpx;
  2044. border-bottom: 1rpx solid #eee;
  2045. }
  2046. .list-title {
  2047. font-size: 26rpx;
  2048. color: #999;
  2049. margin-bottom: 16rpx;
  2050. }
  2051. .device-item {
  2052. display: flex;
  2053. align-items: center;
  2054. padding: 20rpx 0;
  2055. border-radius: 12rpx;
  2056. }
  2057. .device-item.active {
  2058. background: #fff5f0;
  2059. }
  2060. .device-info {
  2061. margin-left: 20rpx;
  2062. display: flex;
  2063. flex-direction: column;
  2064. }
  2065. .device-name {
  2066. font-size: 30rpx;
  2067. color: #333;
  2068. }
  2069. .device-id {
  2070. font-size: 22rpx;
  2071. color: #999;
  2072. margin-top: 4rpx;
  2073. }
  2074. .log-section {
  2075. background: #fff;
  2076. border-radius: 24rpx;
  2077. padding: 24rpx;
  2078. }
  2079. .log-title {
  2080. display: flex;
  2081. justify-content: space-between;
  2082. font-size: 28rpx;
  2083. color: #666;
  2084. padding: 8rpx 0;
  2085. }
  2086. .log-toggle {
  2087. color: #ff5c03;
  2088. font-size: 26rpx;
  2089. }
  2090. .log-list {
  2091. max-height: 360rpx;
  2092. margin-top: 16rpx;
  2093. padding: 16rpx;
  2094. background: #f5f5f5;
  2095. border-radius: 12rpx;
  2096. }
  2097. .log-line {
  2098. display: block;
  2099. font-size: 22rpx;
  2100. color: #666;
  2101. line-height: 1.6;
  2102. word-break: break-all;
  2103. }
  2104. </style>