Files
akbleserver/components/ringcard.uvue
2026-03-16 10:37:46 +08:00

439 lines
14 KiB
Plaintext
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
<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>