Initial commit of akmon project

This commit is contained in:
2026-01-20 08:04:15 +08:00
commit 77a2bab985
1309 changed files with 343305 additions and 0 deletions

View File

@@ -0,0 +1,184 @@
// 告警消息解析工具
// 参考 doc_eldercare/alert.md 设计
export type AlertParseResult {
type : string; // 解析后的类型,如 'SOS', '健康数据', '定位', '通知', '计步', '围栏', '语音', '睡眠', '手表', '未知'
title : string; // 简要标题
content : string; // 展示内容
time ?: string; // 时间
raw : any; // 原始数据
level ?: 'normal' | 'warn' | 'danger'; // 可选,紧急等级
mid ?: string; // 设备ID
smid ?: string; // 消息ID
extra ?: UTSJSONObject; // 其它解析字段
}
/**
* 解析推送消息主入口
* @param msg 传入ps_push_msg_raw的raw_data对象或完整record
*/
export const parseAlertMessage = (msg : UTSJSONObject) : AlertParseResult => {
// 兼容 record/raw_data
let data : UTSJSONObject = msg.getJSON('raw_data') ?? {};
const pushType = data.getNumber('pushType') ?? 0;
const action = data.getNumber('actionRaw') ?? 0;
const time = data.getString('Time') ?? data.getString('time') ?? data.getString('created_at') ?? '';
// 1. SOS
if (pushType === 1) {
return {
type: 'SOS',
title: 'SOS求救',
content: `${data.getString('Name') ?? ''}(${data.getString('MID') ?? ''}) 向您发出求救`,
time,
raw: msg,
level: 'danger',
mid: data.getString('MID') ?? data.getString('mid'),
smid: data.getString('SMID') ?? data.getString('smid'),
extra: { lon: data.getNumber('Lon'), lat: data.getNumber('Lat'), address: data.getString('Str') }
};
}
// 2. 健康数据
if (pushType === 2) {
let arr : string[] = [];
const h = data.getNumber('H');
const o = data.getNumber('O');
const w = data.getNumber('W');
const x = data.getNumber('X');
const y = data.getNumber('Y');
if (h != null) arr.push(`心率:${h}`);
if (o != null) arr.push(`血氧:${o}`);
if (w != null) arr.push(`体温:${w}`);
if (x != null) arr.push(`高压:${x}`);
if (y != null) arr.push(`低压:${y}`);
return {
type: '健康数据',
title: '健康数据推送',
content: arr.length > 0 ? arr.join('') : JSON.stringify(data),
time,
raw: msg,
level: 'normal',
mid: data.getString('MID') ?? data.getString('mid'),
smid: data.getString('SMID') ?? data.getString('smid'),
extra: { mid: data.getString('MID') ?? data.getString('mid') }
};
}
// 3. 定位
if (pushType === 3) {
return {
type: '定位',
title: '定位推送',
content: `${data.getString('Pro') ?? ''}${data.getString('City') ?? ''}${data.getString('Dist') ?? ''}${data.getString('Str') ?? ''} (${data.getNumber('Lon')},${data.getNumber('Lat')})`,
time: data.getString('CT') ?? data.getString('UT') ?? time,
raw: msg,
level: 'normal',
mid: data.getString('MID') ?? data.getString('mid'),
smid: data.getString('SMID') ?? data.getString('smid'),
extra: { lon: data.getNumber('Lon'), lat: data.getNumber('Lat') }
};
}
// 4. 通知
if (pushType === 4) {
const actionMapObj = {
'-1': '设备在线离线', '4': '围栏内停留', '5': '离开围栏', '6': '进入围栏', '9': '低电报警', '11': '跌倒报警', '23': '高温报警',
'26': '断开wifi', '28': 'wifi离线', '36': '防盗报警', '42': '布防告警', '44': '在家布防告警', '7': 'SOS报警', '10': '摘除报警',
'22': '低温报警', '24': '更换SIM卡', '27': '连接wifi', '35': '社区养老报警', '37': '状态通知', '43': '撤防告警', '45': '八件套报警',
'47': 'wifi不一致报警', '49': '红外报警', '50': 'NB按键报警', '51': 'NB防拆报警', '52': 'NB报警复位', '61': 'NB设备报警',
'63': '人体存在报警', '67': 'NB测试报警', '85': '网关上线', '87': '删除子设备', '114': '烟感/气感/门磁事件', '116': 'SCA事件',
'118': '防跌倒雷达', '121': '智能胸牌告警', '84': '网关离线', '86': '添加子设备', '113': '门磁事件', '115': '拉绳SOS',
'117': '4G视频门磁', '119': 'd5网关子设备报警', '122': 'NB温湿度报警', '123': '气感报警', '125': '水浸报警', '127': '跌倒报警',
'129': '燃气报警', '131': '对讲SOS', '134': 'AI智能报警', '124': '烟感报警', '126': '摄像头报警', '128': '井盖报警',
'130': '红外报警', '132': 'ZML_SOS报警', '200': '设备信息变更'
};
let actionStr:string = actionMapObj.getString(action.toString()) ?? `通知类型:${action}`;
return {
type: '通知',
title: actionStr,
content: `${data.getString('Name') ?? ''} ${data.getString('Content') ?? ''}`.trim() ?? JSON.stringify(data),
time,
raw: msg,
level: (action === 9 || action === 11 || action === 23 || action === 7) ? 'danger' : 'warn',
mid: data.getString('MID') ?? data.getString('mid'),
smid: data.getString('SMID') ?? data.getString('smid'),
extra: { action }
};
}
// 5. 计步/翻转pushType=5 或有 Step/Roll 字段)
if (pushType === 6) {
return {
type: '计步',
title: '计步/翻转',
content: `步数:${data.getNumber('Step') ?? '-'} 翻转:${data.getNumber('Roll') ?? '-'}`,
time,
raw: msg,
level: 'normal',
mid: data.getString('MID') ?? data.getString('mid'),
smid: data.getString('SMID') ?? data.getString('smid'),
extra: { step: data.getNumber('Step'), roll: data.getNumber('Roll'), mid: data.getString('MID') ?? data.getString('mid'), smid: data.getString('SMID') ?? data.getString('smid') }
};
}
// 6. 围栏(进出围栏、进入围栏、离开围栏等)
if (pushType === 7 || (pushType === 4 && (action === 6 || action === 5))) {
let actionStr = '';
if (pushType === 4) {
if (action === 6) actionStr = '进入围栏';
else if (action === 5) actionStr = '离开围栏';
else actionStr = '围栏事件';
} else {
actionStr = '进出围栏';
}
return {
type: '围栏',
title: actionStr,
content: data.getString('Content') ?? JSON.stringify(data),
time,
raw: msg,
level: 'warn',
mid: data.getString('MID') ?? data.getString('mid'),
smid: data.getString('SMID') ?? data.getString('smid'),
extra: { lon: data.getNumber('Lon'), lat: data.getNumber('Lat'), action }
};
}
// 7. 微聊语音
if (pushType === 8) {
let msgType = data.getNumber('msgType') ?? data.getNumber('MType') ?? 0;
let typeStr = msgType === 2 ? '语音' : '文字';
return {
type: '微聊',
title: `微聊${typeStr}消息`,
content: data.getString('content') ?? JSON.stringify(data),
time,
raw: msg,
level: 'normal',
mid: data.getString('MID') ?? data.getString('mid'),
smid: data.getString('SMID') ?? data.getString('smid'),
extra: { msgType }
};
}
// 8. 睡眠带报警
if (pushType === 9) {
return {
type: '睡眠报警',
title: '睡眠带报警',
content: data.getString('Content') ?? JSON.stringify(data),
time,
raw: msg,
level: 'warn',
mid: data.getString('MID') ?? data.getString('mid'),
smid: data.getString('SMID') ?? data.getString('smid'),
extra: { action }
};
}
// 9. 其它类型可继续补充...
// 默认
return {
type: '未知',
title: '未知推送',
content: JSON.stringify(data),
time,
raw: msg,
mid: data.getString('MID') ?? data.getString('mid'),
smid: data.getString('SMID') ?? data.getString('smid')
};
};

View File

@@ -0,0 +1,80 @@
<!-- 健康提醒列表 - uts-android 兼容版 -->
<template>
<view class="alerts-list-page">
<view class="header">
<text class="header-title">健康提醒</text>
</view>
<view v-for="alert in alerts" :key="alert.id" class="alert-item">
<text class="alert-title">{{ alert.title }}</text>
<text class="alert-desc">{{ alert.description }}</text>
<text class="alert-patient">患者: {{ alert.elder_name }}</text>
<text class="alert-time">{{ alert.created_at }}</text>
</view>
</view>
</template>
<script setup lang="uts">
import { ref } from 'vue'
import supa from '@/components/supadb/aksupainstance.uts'
const alerts = ref<any[]>([])
const loadAlert = async () => {
const result = await supa.from('ec_health_alerts')
.select('*', { count: 'exact' })
.order('created_at', { ascending: false })
.limit(100)
.execute()
if (result.data != null) alerts.value = result.data
}
onLoad(() => {
loadAlert()
})
</script>
<style lang="scss">
.alerts-list-page {
padding: 20px;
background: #f5f5f5;
min-height: 100vh;
}
.header {
padding: 20px 0 10px 0;
text-align: center;
}
.header-title {
font-size: 22px;
font-weight: bold;
}
.alert-item {
background: #fff;
border-radius: 8px;
margin-bottom: 16px;
padding: 14px;
}
.alert-title {
font-size: 16px;
font-weight: bold;
color: #333;
}
.alert-desc {
font-size: 14px;
color: #555;
margin: 6px 0;
display: block;
}
.alert-patient {
font-size: 13px;
color: #888;
display: block;
}
.alert-time {
font-size: 12px;
color: #aaa;
display: block;
margin-top: 4px;
}
</style>

View File

@@ -0,0 +1,98 @@
<template>
<view class="alert-container">
<view class="top-bar">
<button @click="goRealtime" style="margin-right: 16rpx;">实时告警</button>
<text class="title">历史健康告警</text>
</view>
<view class="section" style="margin-bottom: 24rpx;">
<button @click="loadHistory">刷新历史</button>
</view>
<scroll-view style="height: 600rpx; border: 1px solid #ccc; padding: 8rpx;" scroll-y scroll-with-animation>
<view v-for="(msg, idx) in messages" :key="idx" style="font-size: 26rpx; color: #333; margin-bottom: 12rpx;">
<text>{{ msg.timeStr }}</text>
<text>{{ msg.content }}</text>
<text>{{ msg.raw_data }}</text>
</view>
</scroll-view>
</view>
</template>
<script lang="uts">
type AlertMessage = {
type: string;
mid?:string;
content: string;
timeStr: string;
raw_data:string
};
import { parseAlertMessage } from './alertparse.uts';
import supa from '@/components/supadb/aksupainstance.uts';
export default {
data() {
return {
/** 历史告警消息列表,强类型 */
messages: [] as AlertMessage[],
loading: false
};
},
methods: {
goRealtime() {
uni.navigateTo({ url: '/pages/ec/health/ecalert' });
},
async loadHistory() {
this.loading = true;
let historyList: any[] = [];
try {
// UTS Android 风格获取 Supabase 查询结果
const resp = await supa.from('ps_push_msg_raw').select('*',{}).order('created_at', { ascending: false }).limit(100).execute();
if (resp && resp.error) {
uni.showToast({ title: '获取历史告警失败', icon: 'none' });
} else if (resp && Array.isArray(resp.data)) {
historyList = resp.data;
} else {
historyList = [];
}
} catch (e) {
uni.showToast({ title: '请求异常', icon: 'none' });
}
this.messages = (historyList || []).map((item): AlertMessage => {
// item.raw_data 可能为 UTSJSONObjectparseAlertMessage 返回强类型对象
const parseResult = parseAlertMessage(item.raw_data ? item.raw_data : item);
return {
type: parseResult.type,
mid:parseResult.mid,
content: (parseResult.mid ? `[${parseResult.mid}] ` : '') + parseResult.title + (parseResult.content ? (': ' + parseResult.content) : ''),
timeStr: parseResult.time || '',
raw_data: item.raw_data
};
});
this.loading = false;
}
},
onLoad() {
this.loadHistory();
}
};
</script>
<style>
.alert-container {
padding: 32rpx;
}
.title {
font-size: 36rpx;
font-weight: bold;
margin-bottom: 32rpx;
}
.section {
margin-bottom: 24rpx;
}
.top-bar {
display: flex;
align-items: center;
margin-bottom: 24rpx;
}
</style>

View File

@@ -0,0 +1,157 @@
<template>
<view class="alert-container">
<view class="top-bar">
<button @click="goHistory" style="margin-right: 16rpx;">历史告警</button>
<text class="title">健康告警推送</text>
</view>
<scroll-view style="height: 600rpx; border: 1px solid #ccc; padding: 8rpx;" direction="vertical" scroll-with-animation>
<view v-for="(msg, idx) in messages" :key="idx"
style="font-size: 26rpx; color: #333; margin-bottom: 12rpx;">
<text>{{ msg.timeStr }}</text>
<text>{{ msg.content }}</text>
</view>
</scroll-view>
</view>
</template>
<script lang="uts">
import { parseAlertMessage } from './alertparse.uts';
import { AkSupaRealtime } from '@/components/supadb/aksuparealtime.uts';
import { SUPA_KEY, WS_URL } from '@/ak/config.uts';
export default {
data() {
return {
wsUrl: WS_URL,
channel: 'realtime:public:ps_push_msg_raw',
messages: [] as Array<{ content : string; timeStr : string }>,
realtime: null as AkSupaRealtime | null
};
},
onLoad() {
this.initRealtime();
},
onUnload() {
if (this.realtime) {
this.realtime.close({});
this.realtime = null;
}
},
onShow() {
// 页面回到前台时检查 WebSocket 连接状态,必要时重连
if (!this.realtime || !this.realtime.isOpen) {
console.log('onShow: WebSocket未连接尝试重连');
// this.initRealtime();
}
},
methods: {
goHistory() {
uni.navigateTo({ url: '/pages/ec/health/ecalert-history' });
},
// 重连相关参数
reconnectDelay: 3000,
reconnectMax: 10,
reconnectCount: 0,
reconnectTimer: null as any,
initRealtime() {
if (this.realtime) {
this.realtime.close({});
this.realtime = null;
}
const wsUrl: string = this.wsUrl as string;
const channel: string = this.channel;
const self = this;
this.reconnectCount = 0;
const createRealtime = () => {
const newRealtime = new AkSupaRealtime({
url: wsUrl,
channel: channel,
apikey: SUPA_KEY,
onOpen() {
self.reconnectCount = 0;
if (self.reconnectTimer) {
clearTimeout(self.reconnectTimer);
self.reconnectTimer = null;
}
},
onClose() {
// 断开后自动重连
self.tryReconnect();
},
onError(err) {
// 错误后也尝试重连
self.tryReconnect();
},
onMessage(data) {
console.log(data)
if (data && typeof data === 'object' && data.event === 'INSERT' && data.payload && data.payload.record) {
const payload = data.payload;
let content = '';
let timeStr = '';
const record = payload.record;
if (record.raw_data) {
content = record.raw_data;
} else if (record.message) {
content = record.message;
} else {
content = JSON.stringify(record);
}
if (record.created_at) {
try {
const d = new Date(record.created_at);
timeStr = d.toLocaleString();
} catch (e) {
timeStr = record.created_at;
}
}
const parseResult = parseAlertMessage(record.raw_data ? record.raw_data : record);
console.log(parseResult)
console.log(this.messages, self.messages)
self.messages.unshift({
content: (parseResult.mid ? `[${parseResult.mid}] ` : '') + parseResult.title + (parseResult.content ? (': ' + parseResult.content) : ''),
timeStr: timeStr || parseResult.time || ''
});
if (self.messages.length > 100) self.messages.length = 100;
}
}
});
self.realtime = newRealtime;
newRealtime.connect();
};
createRealtime();
},
tryReconnect() {
if (this.reconnectCount >= this.reconnectMax) {
console.warn('WebSocket重连已达最大次数');
return;
}
if (this.reconnectTimer) {
clearTimeout(this.reconnectTimer);
this.reconnectTimer = null;
}
this.reconnectCount++;
this.reconnectTimer = setTimeout(() => {
console.log('WebSocket重连中...', this.reconnectCount);
// this.initRealtime();
}, this.reconnectDelay);
}
}
};
</script>
<style>
.alert-container {
padding: 32rpx;
}
.title {
font-size: 36rpx;
font-weight: bold;
margin-bottom: 32rpx;
}
.top-bar {
display: flex;
align-items: center;
margin-bottom: 24rpx;
}
</style>

View File

@@ -0,0 +1,41 @@
<!-- 养老管理系统 - 健康检查快捷入口 -->
<template>
<view class="quick-check-page">
<view class="header">
<text class="title">健康检查</text>
</view>
<view class="content">
<text class="desc">此处可快速录入健康检查数据或跳转到健康详情页面。</text>
<!-- 可根据实际需求补充表单或快捷入口 -->
</view>
</view>
</template>
<script setup lang="uts">
// 可根据实际需求补充逻辑
</script>
<style scoped>
.quick-check-page {
min-height: 100vh;
background: #f5f5f5;
padding: 20px;
}
.header {
margin-bottom: 20px;
}
.title {
font-size: 22px;
font-weight: bold;
color: #333;
}
.content {
background: #fff;
border-radius: 8px;
padding: 20px;
}
.desc {
color: #999;
font-size: 14px;
}
</style>