439 lines
14 KiB
Plaintext
439 lines
14 KiB
Plaintext
<template>
|
||
<view
|
||
ref="fullscreenCard"
|
||
class="ring-card"
|
||
:style="[connected ? connectedstyle : '', isFullscreen ? fullscreenstyle : '']"
|
||
>
|
||
<!-- 顶部信息区(可选:全屏时可移到左侧) -->
|
||
<!-- <view class="top-info"> ... </view> -->
|
||
|
||
<view class="main-content">
|
||
<!-- 左侧信息+操作栏 -->
|
||
<view class="side-bar">
|
||
<view class="info">
|
||
<text class="name">{{ name ?? '未知设备' }}</text>
|
||
<text class="id" v-if="isFullscreen">({{ deviceId ?? '' }})</text>
|
||
<text>电量: {{ batteryText }}</text>
|
||
<text>时间: {{ time ?? '--' }}</text>
|
||
<text>步数: {{ steps ?? '--' }}</text>
|
||
<text>软件版本: {{ swVersion ?? '--' }}</text>
|
||
<text>硬件版本: {{ hwVersion ?? '--' }}</text>
|
||
<text>血氧: {{ spo2Text }}</text>
|
||
<text>心率: {{ heartRate != null ? heartRate : '--' }}</text>
|
||
<text>体温: {{ temperatureText }}</text>
|
||
</view>
|
||
<view class="actions-vertical">
|
||
<button @click="connect" v-if="!connected">连接</button>
|
||
<button @click="disconnect" v-if="connected">断开</button>
|
||
<button @click="refreshInfo" v-if="connected">刷新</button>
|
||
<button @click="measureSpo2WithEvents" v-if="connected && oximetryMeasuring==false">测血氧</button>
|
||
<button disabled v-if="oximetryMeasuring">测量中...</button>
|
||
<button @click="gotoHistory">历史数据</button>
|
||
<button @click="onFullscreen" v-if="!isFullscreen" class="fullscreen-btn">全屏</button>
|
||
<button @click="onFullscreen" v-if="isFullscreen" class="fullscreen-exit-btn">退出全屏</button>
|
||
</view>
|
||
</view>
|
||
<!-- 中间ak-charts和AI评论 -->
|
||
<view class="center-area">
|
||
<ak-charts :option="ppgChartOption" canvas-id="ppg-canvas"
|
||
style="width: 100%; height: 40vh; background: #fff; border: 1px solid #eee; margin-top: 10px;" />
|
||
<view class="ai-comment">
|
||
<text>AI评论区</text>
|
||
<!-- 这里可以插入AI分析结果 -->
|
||
</view>
|
||
</view>
|
||
</view>
|
||
</view>
|
||
</template>
|
||
|
||
<script lang="uts">
|
||
// Platform-specific entrypoint: import per-platform index to avoid bundler pulling Android-only code into web builds
|
||
// #ifdef APP-ANDROID
|
||
import { bluetoothService } from '@/uni_modules/ak-sbsrv/utssdk/app-android/index.uts'
|
||
// #endif
|
||
// #ifdef WEB
|
||
import { bluetoothService } from '@/uni_modules/ak-sbsrv/utssdk/web/index.uts'
|
||
// #endif
|
||
import {BleEvent, BleEventPayload} from '@/uni_modules/ak-sbsrv/utssdk/interface.uts'
|
||
import { ProtocolHandler } from '@/uni_modules/ak-sbsrv/utssdk/protocol-handler.uts'
|
||
import AkCharts from '@/uni_modules/ak-charts/components/ak-charts.uvue'
|
||
|
||
type PpgWaveformItem = {
|
||
red: number;
|
||
ir: number;
|
||
x: number;
|
||
y: number;
|
||
z: number;
|
||
}
|
||
type PpgWaveformPacket= {
|
||
seq: number;
|
||
num: number;
|
||
data: PpgWaveformItem[];
|
||
}
|
||
|
||
export default {
|
||
components: {
|
||
AkCharts
|
||
},
|
||
props: {
|
||
name: String,
|
||
deviceId: String,
|
||
isFullscreen: {
|
||
type: Boolean,
|
||
default: false
|
||
}
|
||
},
|
||
data() {
|
||
return {
|
||
connectedstyle:'border-color: #4caf50;',
|
||
fullscreenstyle:'padding: 24px;',
|
||
connected: false,
|
||
battery: null as number | null,
|
||
time: null as string | null,
|
||
steps: null as number | null,
|
||
swVersion: null as string | null,
|
||
hwVersion: null as string | null,
|
||
spo2: null as number | null,
|
||
heartRate: null as number | null,
|
||
temperature: null as number | null,
|
||
oximetryMeasuring: false as boolean,
|
||
handler: null as ProtocolHandler | null,
|
||
refreshTimer: null as number | null, // 定时器句柄
|
||
oximetryTimer: null as number | null,
|
||
ppgWaveforms: [] as PpgWaveformPacket[],
|
||
isBigScreen: false
|
||
}
|
||
},
|
||
mounted() {
|
||
// 判断是否为大屏
|
||
uni.getSystemInfo({
|
||
success: (res) => {
|
||
this.isBigScreen = res.screenWidth >= 900; // 900px 仅为示例
|
||
}
|
||
});
|
||
|
||
},
|
||
computed: {
|
||
batteryBarStyle(): any {
|
||
const battery = this.battery;
|
||
let width = (battery != null ? battery : 0) + '%';
|
||
let background = '#ccc';
|
||
if (battery != null) {
|
||
if (battery < 20) background = '#f44336';
|
||
else if (battery < 50) background = '#ff9800';
|
||
else background = '#4caf50';
|
||
}
|
||
return { width, background };
|
||
},
|
||
batteryText(): string {
|
||
const battery = this.battery;
|
||
return battery != null ? battery.toString() + '%' : '--';
|
||
},
|
||
spo2Text(): string {
|
||
const spo2 = this.spo2;
|
||
return spo2 != null ? spo2.toString() + '%' : '--';
|
||
},
|
||
temperatureText(): string {
|
||
const temperature = this.temperature;
|
||
return temperature != null ? temperature.toString() + '℃' : '--';
|
||
},
|
||
// ak-charts option
|
||
ppgChartOption(): any {
|
||
// 展示最近240个点
|
||
const redPoints: number[] = [];
|
||
const irPoints: number[] = [];
|
||
for (let i = Math.max(0, this.ppgWaveforms.length - 20); i < this.ppgWaveforms.length; i++) {
|
||
const packet = this.ppgWaveforms[i];
|
||
for (let j = 0; j < packet.data.length; j++) {
|
||
redPoints.push(packet.data[j].red);
|
||
irPoints.push(packet.data[j].ir);
|
||
}
|
||
}
|
||
const labels = redPoints.map((_, idx) => idx.toString());
|
||
return {
|
||
type: 'line',
|
||
data: redPoints,
|
||
// series: [
|
||
// { name: '红光', data: redPoints, color: '#4caf50' },
|
||
// { name: '红外', data: irPoints, color: '#f44336' }
|
||
// ],
|
||
labels: labels,
|
||
smooth: true
|
||
};
|
||
},
|
||
},
|
||
methods: {
|
||
async connect() {
|
||
try {
|
||
await bluetoothService.connectDevice(this.deviceId!, 'BLE', { timeout: 30000 });
|
||
this.connected = true;
|
||
await this.initHandler();
|
||
await this.refreshInfo();
|
||
this.clearRefreshTimer();
|
||
this.refreshTimer = setInterval(() => {
|
||
this.refreshInfo();
|
||
}, 300 * 1000);
|
||
} catch (e) {
|
||
console.log(e)
|
||
}
|
||
},
|
||
async disconnect() {
|
||
await bluetoothService.disconnectDevice(this.deviceId!, 'BLE');
|
||
this.connected = false;
|
||
this.handler = null;
|
||
this.clearRefreshTimer();
|
||
},
|
||
clearRefreshTimer() {
|
||
const timer = this.refreshTimer;
|
||
if (timer != null) {
|
||
clearInterval(timer);
|
||
this.refreshTimer = null;
|
||
}
|
||
},
|
||
async initHandler() {
|
||
const res = await bluetoothService.getAutoBleInterfaces(this.deviceId!);
|
||
const handler = new ProtocolHandler(bluetoothService);
|
||
handler.setConnectionParameters(this.deviceId!, res.serviceId, res.writeCharId, res.notifyCharId);
|
||
await handler.initialize();
|
||
handler.onPushData((data) => {
|
||
console.log(data)
|
||
// ...主动推送处理...
|
||
|
||
// 波形响应
|
||
if (data.length >= 6 && data[2] === 0x32 && data[1] ==0x00) {
|
||
console.log(data[3])
|
||
this.parseBloodOxygenWave(data);
|
||
|
||
}
|
||
});
|
||
this.handler = handler;
|
||
},
|
||
async refreshInfo() {
|
||
if (this.handler == null) await this.initHandler();
|
||
const handler = this.handler;
|
||
if (handler != null) {
|
||
this.battery = await handler.testBatteryLevel();
|
||
this.swVersion = await handler.testVersionInfo(false);
|
||
this.hwVersion = await handler.testVersionInfo(true);
|
||
this.time = await handler.testGetTime();
|
||
this.steps = await handler.testStepCount();
|
||
}
|
||
},
|
||
async measureBattery() {
|
||
if (!this.connected) await this.connect();
|
||
if (this.handler == null) await this.initHandler();
|
||
const handler = this.handler;
|
||
if (handler != null) {
|
||
this.battery = await handler.testBatteryLevel();
|
||
return this.battery;
|
||
}
|
||
return null;
|
||
},
|
||
async clearSteps() {
|
||
if (!this.connected) await this.connect();
|
||
if (this.handler == null) await this.initHandler();
|
||
const handler = this.handler;
|
||
if (handler != null && typeof handler.testClearStepCount() === 'function') {
|
||
await handler.testClearStepCount();
|
||
this.steps = 0;
|
||
}
|
||
},
|
||
async measureSpo2WithEvents() {
|
||
if (!this.connected) await this.connect();
|
||
if (this.handler == null) await this.initHandler();
|
||
const handler = this.handler;
|
||
if (handler != null) {
|
||
this.oximetryMeasuring = true;
|
||
// 清理上一次的定时器
|
||
const timer = this.oximetryTimer;
|
||
if (timer != null) {
|
||
clearTimeout(timer);
|
||
this.oximetryTimer = null;
|
||
}
|
||
// 设置测量超时时间(如60秒)
|
||
this.oximetryTimer = setTimeout(() => {
|
||
this.oximetryMeasuring = false;
|
||
handler.oximetryWithEvents({ stop: true }, null);
|
||
uni.showToast({ title: '血氧测量已结束', icon: 'none' });
|
||
this.oximetryTimer = null;
|
||
}, 60000); // 60秒
|
||
|
||
handler.oximetryWithEvents({}, (data: Uint8Array) => {
|
||
console.log(data);
|
||
// 结果响应
|
||
if (data.length >= 9 && data[2] === 0x32 && data[3] === 0x00) {
|
||
this.heartRate = data[5];
|
||
this.spo2 = data[6];
|
||
let tempRaw = (data[8] << 8) | data[7];
|
||
if ((tempRaw & 0x8000) > 0) tempRaw = tempRaw - 0x10000;
|
||
this.temperature = tempRaw / 100;
|
||
}
|
||
// 波形响应
|
||
if (data.length >= 6 && data[2] === 0x32 && data[3] === 0x01) {
|
||
const frameid = data[1];
|
||
if (frameid === 0) { // 只处理frameid为0的包
|
||
const seq = data[4];
|
||
const num = data[5];
|
||
const waveArr: PpgWaveformItem[] = [];
|
||
for (let i = 0; i < num; i++) {
|
||
const base = 6 + i * 14;
|
||
if ((base + 13) >= data.length) break;
|
||
let red = (data[base + 3] << 24) | (data[base + 2] << 16) | (data[base + 1] << 8) | data[base + 0];
|
||
if ((red & 0x80000000) !== 0) red = red - 0x100000000;
|
||
let ir = (data[base + 7] << 24) | (data[base + 6] << 16) | (data[base + 5] << 8) | data[base + 4];
|
||
if ((ir & 0x80000000) !== 0) ir = ir - 0x100000000;
|
||
let x = (data[base + 9] << 8) | data[base + 8];
|
||
if ((x & 0x8000) !== 0) x = x - 0x10000;
|
||
let y = (data[base + 11] << 8) | data[base + 10];
|
||
if ((y & 0x8000) !== 0) y = y - 0x10000;
|
||
let z = (data[base + 13] << 8) | data[base + 12];
|
||
if ((z & 0x8000) !== 0) z = z - 0x10000;
|
||
waveArr.push({ red, ir, x, y, z });
|
||
}
|
||
// 按seq去重拼包
|
||
const idx = this.ppgWaveforms.findIndex(p => p.seq === seq);
|
||
if (idx >= 0) {
|
||
this.ppgWaveforms[idx] = { seq, num, data: waveArr };
|
||
} else {
|
||
this.ppgWaveforms.push({ seq, num, data: waveArr });
|
||
}
|
||
// ak-charts 自动响应数据变化,无需手动draw
|
||
}
|
||
}
|
||
// 波形结束通知
|
||
// if (data.length >= 4 && data[2] === 0x32 && data[3] === 0xFF) {
|
||
// this.oximetryMeasuring = false;
|
||
// if (this.oximetryTimer != null) {
|
||
// const timer = this.oximetryTimer as number;
|
||
// clearTimeout(timer);
|
||
// this.oximetryTimer = null;
|
||
// }
|
||
// }
|
||
});
|
||
}
|
||
},
|
||
onConnectionStateChanged(state: number) {
|
||
if (state === 0) {
|
||
this.connected = false;
|
||
this.handler = null;
|
||
this.clearRefreshTimer();
|
||
// 这里可以加UI变灰等处理
|
||
} else if (state === 2) {
|
||
this.connected = true;
|
||
// 这里可以加UI恢复等处理
|
||
}
|
||
},
|
||
gotoHistory() {
|
||
uni.navigateTo({
|
||
url: `/components/blecommu?deviceId=${this.deviceId}`
|
||
});
|
||
},
|
||
onFullscreen() {
|
||
if (this.isFullscreen) {
|
||
this.$emit('exit-fullscreen', this.deviceId);
|
||
} else {
|
||
this.$emit('request-fullscreen', this.deviceId);
|
||
}
|
||
},
|
||
parseBloodOxygenWave(data: Uint8Array) {
|
||
const seq = data[4];
|
||
const num = data[5];
|
||
const waveArr: PpgWaveformItem[] = [];
|
||
for (let i = 0; i < num; i++) {
|
||
const base = 6 + i * 14;
|
||
if ((base + 13) >= data.length) break;
|
||
// 红色PPG
|
||
let red = (data[base + 3] << 24) | (data[base + 2] << 16) | (data[base + 1] << 8) | data[base + 0];
|
||
if ((red & 0x80000000) !== 0) red = red - 0x100000000;
|
||
// 红外PPG
|
||
let ir = (data[base + 7] << 24) | (data[base + 6] << 16) | (data[base + 5] << 8) | data[base + 4];
|
||
if ((ir & 0x80000000) !== 0) ir = ir - 0x100000000;
|
||
// X加速度
|
||
let x = (data[base + 9] << 8) | data[base + 8];
|
||
if ((x & 0x8000) !== 0) x = x - 0x10000;
|
||
// Y加速度
|
||
let y = (data[base + 11] << 8) | data[base + 10];
|
||
if ((y & 0x8000) !== 0) y = y - 0x10000;
|
||
// Z加速度
|
||
let z = (data[base + 13] << 8) | data[base + 12];
|
||
if ((z & 0x8000) !== 0) z = z - 0x10000;
|
||
waveArr.push({ red, ir, x, y, z });
|
||
}
|
||
console.log(seq, num, data)
|
||
this.ppgWaveforms.push({ seq, num, data: waveArr });
|
||
}
|
||
},
|
||
beforeUnmount() {
|
||
this.clearRefreshTimer();
|
||
},
|
||
|
||
}
|
||
</script>
|
||
|
||
<style scoped>
|
||
.ring-card {
|
||
display: flex;
|
||
flex-direction: column;
|
||
width: 100vw;
|
||
height: 100vh;
|
||
background: #ffffff;
|
||
overflow: auto;
|
||
padding: 0;
|
||
}
|
||
.main-content {
|
||
flex: 1;
|
||
display: flex;
|
||
flex-direction: row;
|
||
width: 100%;
|
||
height: 100%;
|
||
}
|
||
.side-bar {
|
||
width: 20%;
|
||
min-width: 180px;
|
||
max-width: 320px;
|
||
display: flex;
|
||
flex-direction: column;
|
||
align-items: flex-start;
|
||
justify-content: flex-start;
|
||
background: #f8f8f8;
|
||
padding: 24px 12px 0 24px;
|
||
box-sizing: border-box;
|
||
}
|
||
.info {
|
||
display: flex;
|
||
flex-direction: column;
|
||
gap: 8px;
|
||
font-size: 14px;
|
||
margin-bottom: 20px;
|
||
}
|
||
.actions-vertical {
|
||
display: flex;
|
||
flex-direction: column;
|
||
align-items: flex-start;
|
||
gap: 12px;
|
||
}
|
||
.center-area {
|
||
flex: 1;
|
||
display: flex;
|
||
flex-direction: column;
|
||
align-items: center;
|
||
justify-content: flex-start;
|
||
padding: 24px 0 0 0;
|
||
background-color: #fff;
|
||
}
|
||
.ai-comment {
|
||
margin-top: 24px;
|
||
width: 90%;
|
||
min-height: 60px;
|
||
background: #f5f5f5;
|
||
border-radius: 8px;
|
||
padding: 12px;
|
||
font-size: 15px;
|
||
color: #333;
|
||
}
|
||
.fullscreen-btn, .fullscreen-exit-btn {
|
||
width: 100px;
|
||
height: 36px;
|
||
font-size: 16px;
|
||
}
|
||
</style> |