From b61896d3ba6cc2dbb2bdb124266ca65919381eb8 Mon Sep 17 00:00:00 2001 From: lzh Date: Tue, 7 Apr 2026 13:45:03 +0800 Subject: [PATCH] =?UTF-8?q?docs:=20=E7=A7=BB=E5=8A=A8=E7=AB=AF=E5=BC=80?= =?UTF-8?q?=E5=8F=91=E6=96=87=E6=A1=A3=E6=89=A9=E5=B1=95=20+=20=E9=99=84?= =?UTF-8?q?=E5=BD=95=E6=9C=AF=E8=AF=AD=E8=A1=A8/=E9=94=99=E8=AF=AF?= =?UTF-8?q?=E7=A0=81?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 05-移动端开发: - 01-移动端工程结构与页面分域.md - 补充 UniApp 分包策略(主包 2MB 限制) - 页面路由配置与生命周期规范 - Token 双 Token 无感刷新逻辑 - 内存泄漏防护清单 - 02-硬件交互与弱网离线策略.md - 智能工牌蓝牙通信协议(BadgeStatus/NotifyType) - iBeacon 协议格式与信标配置 - 蓝牙扫描 Hook 实现(useBluetoothScan) - 权限处理与超时机制 08-附录: - 01-术语表.md - IoT 设备术语(工牌、信标、RSSI) - 工单状态机(Mermaid 状态图) - 优先级与触发来源 - 移动端 BLE 术语 - 02-错误码清单.md - 后端统一错误码(全局 + OPS 模块) - 前端 ResultEnum 状态码 - 蓝牙扫描状态与错误处理 - 错误码对照速查表 --- .../05-移动端开发/01-移动端工程结构与页面分域.md | 363 +++-- .../05-移动端开发/02-硬件交互与弱网离线策略.md | 1312 ++++++----------- 开发者文档/08-附录/01-术语表.md | 200 +++ 开发者文档/08-附录/02-错误码清单.md | 242 +++ 4 files changed, 1165 insertions(+), 952 deletions(-) create mode 100644 开发者文档/08-附录/01-术语表.md create mode 100644 开发者文档/08-附录/02-错误码清单.md diff --git a/开发者文档/05-移动端开发/01-移动端工程结构与页面分域.md b/开发者文档/05-移动端开发/01-移动端工程结构与页面分域.md index b0699f6..be850b7 100644 --- a/开发者文档/05-移动端开发/01-移动端工程结构与页面分域.md +++ b/开发者文档/05-移动端开发/01-移动端工程结构与页面分域.md @@ -38,33 +38,71 @@ aiot-uniapp/ │ │ └── helpers.ts # 通用辅助函数 │ │ │ ├── pages/ # 【主包】核心页面 -│ │ ├── login/ -│ │ │ ├── index.vue # 登录页 -│ │ │ └── auth.vue # 静默授权回调 │ │ ├── index/ │ │ │ └── index.vue # 工作台首页(Tab Bar) +│ │ ├── scan/ +│ │ │ ├── index.vue # 扫码入口 +│ │ │ └── bluetooth-debug/ # 蓝牙调试页 +│ │ ├── user/ +│ │ │ └── index.vue # 个人中心 +│ │ └── contact/ +│ │ └── index.vue # 通讯录 +│ │ +│ ├── pages-core/ # 【分包】核心功能(登录、设置) +│ │ ├── auth/ +│ │ │ ├── login.vue # 账号密码登录 +│ │ │ ├── code-login.vue # 验证码登录 +│ │ │ └── forget-password.vue │ │ └── user/ -│ │ └── index.vue # 个人中心 +│ │ ├── profile/ +│ │ ├── settings/ +│ │ └── security/ │ │ │ ├── pages-ops/ # 【分包】Ops 现场作业 │ │ ├── work-order/ -│ │ │ ├── list.vue # 工单列表 +│ │ │ ├── index.vue # 工单列表 │ │ │ ├── detail.vue # 工单详情 -│ │ │ ├── arrival.vue # 到岗打卡(信标扫描) -│ │ │ └── complete.vue # 完工提交 +│ │ │ ├── create.vue # 创建工单 +│ │ │ └── stats.vue # 工单统计 │ │ ├── inspection/ -│ │ │ ├── list.vue # 巡检任务列表 -│ │ │ ├── scan.vue # 扫码巡检 -│ │ │ └── record.vue # 巡检记录填报 -│ │ └── queue/ -│ │ └── index.vue # 我的队列 +│ │ │ ├── index.vue # 巡检任务列表 +│ │ │ ├── detail.vue # 巡检详情 +│ │ │ └── components/ +│ │ │ └── bluetooth-verify.vue # 蓝牙信标验证 +│ │ └── inspection/composables/ +│ │ └── use-bluetooth-scan.ts # 蓝牙扫描 Hook │ │ -│ ├── pages-iot/ # 【分包】IoT 设备管理 -│ │ ├── device/ -│ │ │ ├── list.vue -│ │ │ └── detail.vue -│ │ └── alarm/ -│ │ └── index.vue +│ ├── pages-system/ # 【分包】系统管理 +│ │ ├── area/ +│ │ ├── dept/ +│ │ ├── user/ +│ │ └── dict/ +│ │ +│ ├── pages-bpm/ # 【分包】工作流 +│ │ +│ ├── pages-infra/ # 【分包】基础设施 +│ │ +│ ├── api/ # API 接口定义 +│ │ ├── ops/ # Ops 业务 API +│ │ │ ├── order-center/ # 工单中心 +│ │ │ ├── cleaning/ # 保洁业务 +│ │ │ ├── security/ # 安保业务 +│ │ │ └── inspection/ # 巡检业务 +│ │ ├── iot/ # IoT 设备 API +│ │ ├── system/ # 系统管理 API +│ │ └── login.ts # 登录相关 API +│ │ +│ ├── store/ # Pinia 状态管理 +│ │ ├── token.ts # Token 管理(双 Token 刷新) +│ │ ├── user.ts # 用户信息 +│ │ ├── dict.ts # 字典缓存 +│ │ └── theme.ts # 主题配置 +│ │ +│ ├── http/ # HTTP 请求封装 +│ │ ├── http.ts # 核心 HTTP 方法 +│ │ ├── types.ts # 类型定义 +│ │ └── tools/ +│ │ └── enum.ts # ResultEnum 状态码 │ │ │ ├── static/ # 静态资源 │ │ ├── images/ @@ -94,9 +132,10 @@ aiot-uniapp/ | 目录 | 说明 | 体积控制 | |-----|------|---------| -| `/pages/login/` | 登录、验证码、静默授权页 | < 200KB | | `/pages/index/` | 工作台首页(Tab Bar) | < 300KB | -| `/pages/user/` | 个人中心基础设置 | < 100KB | +| `/pages/scan/` | 扫码入口 | < 200KB | +| `/pages/user/` | 个人中心 | < 100KB | +| `/pages/contact/` | 通讯录 | < 150KB | | `components/` | 全局共享的基础 UI 组件库 | < 200KB | **严禁将具体的业务流程(如填写工单、扫描设备)塞入主包。** @@ -109,27 +148,36 @@ aiot-uniapp/ // pages.json { "pages": [ - { "path": "pages/login/index" }, { "path": "pages/index/index" }, - { "path": "pages/user/index" } + { "path": "pages/scan/index" }, + { "path": "pages/user/index" }, + { "path": "pages/contact/index" } ], "subPackages": [ { - "root": "pages-ops", + "root": "pages-core", "pages": [ - { "path": "work-order/list" }, - { "path": "work-order/detail" }, - { "path": "work-order/arrival" }, - { "path": "inspection/list" }, - { "path": "inspection/scan" } + { "path": "auth/login" }, + { "path": "auth/code-login" }, + { "path": "user/profile/index" } ] }, { - "root": "pages-iot", + "root": "pages-ops", "pages": [ - { "path": "device/list" }, - { "path": "device/detail" }, - { "path": "alarm/index" } + { "path": "work-order/index" }, + { "path": "work-order/detail" }, + { "path": "work-order/create" }, + { "path": "inspection/index" }, + { "path": "inspection/detail" } + ] + }, + { + "root": "pages-system", + "pages": [ + { "path": "area/index" }, + { "path": "dept/index" }, + { "path": "user/index" } ] } ], @@ -150,21 +198,103 @@ aiot-uniapp/ --- -## 三、页面状态与生命周期规约 +## 三、页面路由与导航 -移动端页面的生命周期(`onLoad`, `onShow`, `onReady`)与 Vue 组件(`created`, `mounted`)有重叠,必须规范使用: +### 3.1 路由配置(pages.json) -### 3.1 生命周期使用规范 +```json +{ + "pages": [ + { + "path": "pages/index/index", + "type": "home", + "style": { "navigationStyle": "custom" } + }, + { + "path": "pages/scan/index", + "style": { "navigationBarTitleText": "扫码巡检" } + } + ], + "subPackages": [ + { + "root": "pages-ops", + "pages": [ + { "path": "work-order/index", "style": { "navigationStyle": "custom" } }, + { "path": "work-order/detail", "style": { "navigationStyle": "custom" } }, + { "path": "inspection/index", "style": { "navigationStyle": "custom" } }, + { "path": "inspection/detail", "style": { "navigationStyle": "custom" } } + ] + } + ], + "tabBar": { + "custom": true, + "color": "#999999", + "selectedColor": "#f97316", + "list": [ + { "text": "工作台", "pagePath": "pages/index/index" }, + { "text": "我的", "pagePath": "pages/user/index" } + ] + } +} +``` + +### 3.2 页面跳转方法 + +```typescript +// 基础导航 +import { navigateBackPlus } from '@/utils' + +// 跳转到工单详情(带参数) +function goToWorkOrderDetail(orderId: number) { + uni.navigateTo({ + url: `/pages-ops/work-order/detail?id=${orderId}` + }) +} + +// 返回上一页(封装版,支持指定 fallback) +navigateBackPlus('/pages-ops/work-order/index') + +// 重定向(关闭当前页面) +uni.redirectTo({ url: '/pages/index/index' }) + +// 切换到 TabBar 页面 +uni.switchTab({ url: '/pages/index/index' }) +``` + +### 3.3 页面参数接收 ```vue +``` + +--- + +## 四、页面状态与生命周期规约 + +移动端页面的生命周期(`onLoad`, `onShow`, `onReady`)与 Vue 组件(`created`, `mounted`)有重叠,必须规范使用: + +### 4.1 生命周期使用规范 + +```vue + ``` -### 3.2 内存泄漏防护 +### 4.2 内存泄漏防护 | 资源类型 | 注册位置 | 必须清理位置 | 清理方式 | |---------|---------|-------------|---------| | WebSocket | `onLoad` / `onShow` | `onUnload` / `onHide` | `ws.close()` | | EventBus 监听 | `onLoad` | `onUnload` | `uni.$off()` | -| 蓝牙连接 | 用户操作后 | `onUnload` | `BleManager.disconnect()` | +| 蓝牙连接 | 用户操作后 | `onUnload` | `uni.closeBLEConnection()` | | 定时器 | 任意 | `onUnload` | `clearInterval()` | | 页面级 Store | `onLoad` | `onUnload` | `store.$reset()` | --- -## 四、状态管理设计 +## 五、状态管理设计 -### 4.1 分层状态管理 +### 5.1 分层状态管理 ``` 全局状态 (Pinia Store) -├── userStore # 用户信息、登录态 -├── configStore # 系统配置、字典缓存 -└── bleStore # 蓝牙连接状态、当前工牌 +├── token.ts # Token 管理(双 Token 无感刷新) +├── user.ts # 用户信息、登录态 +├── dict.ts # 系统字典缓存 +└── theme.ts # 主题配置 页面级状态 (Composable / setup) ├── useWorkOrder() # 单个工单页面的状态 ├── useInspection() # 巡检页面的状态 -└── useUpload() # 图片上传的状态 +└── useBluetoothScan() # 蓝牙扫描状态 ``` -### 4.2 Store 示例 +### 5.2 Token 管理(双 Token 无感刷新) ```typescript -// stores/ble.ts -import { defineStore } from 'pinia'; -import { ref, computed } from 'vue'; -import BleManager from '@/services/ble/BleManager'; +// store/token.ts +import { defineStore } from 'pinia' +import { ref } from 'vue' -export const useBleStore = defineStore('ble', () => { - // State - const connectedDevice = ref(null); - const connectionState = ref<'disconnected' | 'connecting' | 'connected'>('disconnected'); - const lastHeartbeat = ref(0); +interface TokenInfo { + accessToken: string + refreshToken: string + expiresTime: number +} - // Getters - const isConnected = computed(() => connectionState.value === 'connected'); - const isDeviceOnline = computed(() => { - if (!lastHeartbeat.value) return false; - return Date.now() - lastHeartbeat.value < 5 * 60 * 1000; // 5分钟内有心跳 - }); - - // Actions - async function connect(deviceId: string) { - connectionState.value = 'connecting'; - try { - const success = await BleManager.connect(deviceId); - if (success) { - connectionState.value = 'connected'; - // 监听心跳 - BleManager.on('heartbeat', (data) => { - lastHeartbeat.value = data.timestamp; - }); - } else { - connectionState.value = 'disconnected'; - } - } catch (error) { - connectionState.value = 'disconnected'; - throw error; - } +export const useTokenStore = defineStore('token', () => { + const tokenInfo = ref(null) + + async function refreshToken() { + // 调用刷新接口,更新 accessToken + const res = await http.post('/system/auth/refresh-token', { + refreshToken: tokenInfo.value?.refreshToken + }) + tokenInfo.value = res } - - function disconnect() { - BleManager.disconnect(); - connectedDevice.value = null; - connectionState.value = 'disconnected'; + + function logout() { + tokenInfo.value = null + uni.removeStorageSync('token_info') } - - return { - connectedDevice, - connectionState, - lastHeartbeat, - isConnected, - isDeviceOnline, - connect, - disconnect - }; -}); + + return { tokenInfo, refreshToken, logout } +}) ``` --- -## 五、关键实现要点 +## 六、关键实现要点 -### 5.1 工程结构红线 +### 6.1 工程结构红线 | 红线项 | 说明 | 后果 | |-------|------|------| @@ -336,22 +440,23 @@ export const useBleStore = defineStore('ble', () => { | **必须预加载** | 首页应预加载高频分包 | 用户点击后白屏时间过长 | | **清理必须配对** | 有注册必须有卸载 | 内存泄漏、页面异常 | -### 5.2 目录命名规范 +### 6.2 目录命名规范 | 类型 | 命名规范 | 示例 | |-----|---------|------| -| 页面目录 | kebab-case | `work-order/`, `inspection-scan/` | -| 组件文件 | kebab-case | `app-badge.vue`, `app-upload.vue` | -| 服务文件 | PascalCase | `BleManager.ts`, `WorkOrderService.ts` | -| 工具文件 | camelCase | `request.ts`, `helpers.ts` | -| 常量文件 | UPPER_SNAKE | `constants.ts` (内部常量) | +| 页面目录 | kebab-case | `work-order/`, `inspection/` | +| 组件文件 | kebab-case | `bluetooth-verify.vue` | +| 服务文件 | PascalCase | `BleManager.ts` | +| 工具文件 | camelCase | `use-bluetooth-scan.ts` | +| API 文件 | camelCase | `order-center/index.ts` | -### 5.3 相关代码入口 +### 6.3 相关代码入口 | 模块 | 文件路径 | 说明 | |-----|---------|------| | 页面路由 | `src/pages.json` | 主包 + 分包配置 | | 应用配置 | `src/manifest.json` | 小程序 AppID、权限声明 | -| 请求封装 | `src/utils/request.ts` | 拦截器、错误处理 | -| 蓝牙服务 | `src/services/ble/BleManager.ts` | BLE 连接管理 | -| 全局组件 | `src/components/` | 复用 UI 组件 | +| 请求封装 | `src/http/http.ts` | 拦截器、Token 刷新、错误处理 | +| 蓝牙扫描 | `src/pages-ops/inspection/composables/use-bluetooth-scan.ts` | iBeacon 扫描 Hook | +| 字典常量 | `src/utils/constants/dict-enum.ts` | DICT_TYPE 定义 | +| 状态码 | `src/http/tools/enum.ts` | ResultEnum 定义 | \ No newline at end of file diff --git a/开发者文档/05-移动端开发/02-硬件交互与弱网离线策略.md b/开发者文档/05-移动端开发/02-硬件交互与弱网离线策略.md index a2e57f0..7fa84ee 100644 --- a/开发者文档/05-移动端开发/02-硬件交互与弱网离线策略.md +++ b/开发者文档/05-移动端开发/02-硬件交互与弱网离线策略.md @@ -7,759 +7,455 @@ AIOT 移动端的核心价值在于与**智能工牌(BADGE)**和**蓝牙信 ## 一、智能工牌(BADGE)蓝牙通信协议 -### 1.1 工牌设备模型 (`IotBadgeDevice`) +### 1.1 工牌设备模型 ```typescript -// 工牌设备核心字段(对应后端 IotBadgeDeviceDO) -interface IotBadgeDevice { - deviceId: string; // 设备唯一ID(如 BD-2024-001) - deviceName: string; // 设备名称 - deviceType: 'BADGE'; // 固定类型 - userId?: string; // 绑定用户ID - areaId?: string; // 所属区域ID - batteryLevel: number; // 电量百分比(0-100) - status: BadgeStatusEnum; // 设备状态 - lastHeartbeat: number; // 最后心跳时间戳 - firmwareVersion: string; // 固件版本 +// api/ops/cleaning/index.ts + +/** 工牌状态枚举 */ +export enum BadgeStatus { + IDLE = 'IDLE', // 空闲 + BUSY = 'BUSY', // 忙碌 + OFFLINE = 'OFFLINE', // 离线 + PAUSED = 'PAUSED', // 暂停中 } -enum BadgeStatusEnum { - IDLE = 'IDLE', // 空闲,可接受新任务 - BUSY = 'BUSY', // 忙碌,正在处理任务 - OFFLINE = 'OFFLINE', // 离线(超过5分钟无心跳) - ERROR = 'ERROR' // 故障状态 +/** 通知类型枚举 */ +export enum NotifyType { + VIBRATE = 'VIBRATE', // 震动 + VOICE = 'VOICE', // 语音 +} + +/** 工牌状态信息 */ +export interface BadgeStatusItem { + deviceId: number + deviceKey: string + status: BadgeStatus + currentAreaId?: number + currentAreaName?: string + batteryLevel: number // 0-100 + lastHeartbeatTime: string + todayCompletedCount?: number + todayWorkMinutes?: number +} + +/** 工牌实时状态 */ +export interface BadgeRealtimeStatus { + deviceId: number + deviceKey: string + status: BadgeStatus | string + batteryLevel: null | number + lastHeartbeatTime: string + rssi?: null | number + isInArea: boolean + areaId?: number + areaName?: null | string } ``` ### 1.2 蓝牙通信架构 -移动端与工牌的通信采用 **BLE(Bluetooth Low Energy)4.0/5.0** 协议栈: - ``` ┌─────────────────────────────────────────────────────────────┐ │ 移动端 (UniApp) │ ├─────────────────────────────────────────────────────────────┤ │ ┌──────────────┐ ┌──────────────┐ ┌──────────────────┐ │ │ │ 业务层 │ │ 蓝牙服务层 │ │ 设备管理层 │ │ -│ │ BadgeService │ │ BleManager │ │ DeviceManager │ │ +│ │ useBluetooth │ │ uni API │ │ BadgeService │ │ +│ │ Scan Hook │ │ │ │ │ │ │ └──────┬───────┘ └──────┬───────┘ └────────┬─────────┘ │ │ │ │ │ │ │ ┌──────▼─────────────────▼───────────────────▼─────────┐ │ │ │ UniApp 蓝牙 API 层 │ │ │ │ uni.openBluetoothAdapter │ │ -│ │ uni.startBluetoothDevicesDiscovery │ │ -│ │ uni.createBLEConnection │ │ -│ │ uni.writeBLECharacteristicValue │ │ -│ │ uni.onBLECharacteristicValueChange │ │ +│ │ uni.startBeaconDiscovery │ │ +│ │ uni.onBeaconUpdate │ │ +│ │ uni.stopBeaconDiscovery │ │ │ └─────────────────────────┬─────────────────────────────┘ │ └────────────────────────────┼───────────────────────────────┘ - │ BLE 广播/连接 + │ BLE 广播 ┌────────────────────────────┼───────────────────────────────┐ -│ 智能工牌硬件 │ +│ 蓝牙信标硬件 │ │ ┌─────────────────────────▼─────────────────────────────┐ │ -│ │ BLE 芯片 (Nordic nRF52 / TI CC2640) │ │ -│ │ ┌─────────────┐ ┌─────────────┐ ┌──────────────┐ │ │ -│ │ │ GATT Server │ │ 按键中断 │ │ 震动马达 │ │ │ -│ │ │ 服务 0xFFF0 │ │ GPIO 中断 │ │ PWM 驱动 │ │ │ -│ │ └─────────────┘ └─────────────┘ └──────────────┘ │ │ +│ │ iBeacon 协议 (Apple) │ │ +│ │ UUID + Major + Minor + RSSI │ │ │ └────────────────────────────────────────────────────────┘ │ └─────────────────────────────────────────────────────────────┘ ``` -### 1.3 BLE 服务与特征值定义 +### 1.3 iBeacon 协议格式 -工牌设备暴露的标准 GATT 服务: +巡检系统使用标准 iBeacon 协议进行定位验证: -| 服务 UUID | 特征值 UUID | 属性 | 说明 | -|-----------|-------------|------|------| -| `0xFFF0` (主服务) | `0xFFF1` | Read/Notify | 设备信息(电量、固件版本) | -| `0xFFF0` (主服务) | `0xFFF2` | Write | 指令下发(震动、LED 控制) | -| `0xFFF0` (主服务) | `0xFFF3` | Notify | 按键事件上报 | -| `0xFFF0` (主服务) | `0xFFF4` | Read/Notify | 信标扫描结果 | - -### 1.4 指令协议格式 - -移动端向工牌下发的指令采用固定长度的二进制帧: - -``` -帧结构(16字节): -┌────────┬────────┬────────┬────────────────┬────────────────┐ -│ 帧头 │ 指令码 │ 参数 │ 工单ID(低8B) │ 校验和 │ -│ 0xAA │ 1字节 │ 1字节 │ 8字节 │ 2字节(CRC16) │ -│ 1字节 │ │ │ │ │ -└────────┴────────┴────────┴────────────────┴────────────────┘ - -指令码定义: -0x01 - 震动提醒(参数=震动时长,单位100ms) -0x02 - LED 闪烁(参数=闪烁次数) -0x03 - 启动信标扫描(参数=扫描时长,单位秒) -0x04 - 清除当前任务 -0x05 - 查询设备状态 -``` - -### 1.5 工牌事件上报格式 - -工牌向移动端上报的事件: +| 字段 | 长度 | 说明 | +|-----|------|------| +| UUID | 16字节 | 信标唯一标识(区域级别) | +| Major | 2字节 | 主标识(区域ID) | +| Minor | 2字节 | 次标识(信标ID) | +| RSSI | 1字节 | 信号强度(dBm) | ```typescript -// 按键事件(特征值 0xFFF3 Notify) -interface BadgeKeyEvent { - eventType: 'KEY_PRESS' | 'KEY_LONG_PRESS'; - keyCode: number; // 按键编号(工牌通常只有1个确认键) - timestamp: number; // 工牌本地时间戳 - taskId?: string; // 关联的工单ID(如果有) +// api/ops/inspection/index.ts + +/** 绑定的信标 */ +export interface InspectionBoundBeaconVO { + major: number + minor: number } -// 信标扫描结果(特征值 0xFFF4 Notify) -interface BeaconScanResult { - scanId: string; // 扫描会话ID - beacons: DetectedBeacon[]; // 检测到的信标列表 - scanDuration: number; // 实际扫描时长(ms) - timestamp: number; // 扫描完成时间戳 -} - -interface DetectedBeacon { - macAddress: string; // 信标MAC地址(大写无冒号) - rssi: number; // 信号强度(dBm) - uuid?: string; // iBeacon UUID(如果是iBeacon格式) - major?: number; // iBeacon Major - minor?: number; // iBeacon Minor +/** 信标配置 */ +export interface InspectionBeaconConfigVO { + uuid: string + boundBeacons: InspectionBoundBeaconVO[] + rssiThreshold: number // 信号强度阈值(如 -75dBm) + timeoutMs: number // 扫描超时时间(毫秒) } ``` --- -## 二、移动端蓝牙服务实现 (`BleManager`) +## 二、蓝牙信标扫描实现(useBluetoothScan) -### 2.1 服务初始化与适配器管理 +### 2.1 扫描 Hook 核心实现 ```typescript -// services/ble/BleManager.ts -class BleManager { - private static instance: BleManager; - private adapterState: 'uninitialized' | 'ready' | 'scanning' | 'error' = 'uninitialized'; - private connectedDevice: BLEDevice | null = null; - private eventCallbacks: Map = new Map(); +// pages-ops/inspection/composables/use-bluetooth-scan.ts - static getInstance(): BleManager { - if (!BleManager.instance) { - BleManager.instance = new BleManager(); - } - return BleManager.instance; +export type ScanStatus = 'idle' | 'scanning' | 'success' | 'failed' | 'no-permission' + +/** 匹配到的信标信息 */ +export interface MatchedBeacon { + major: number + minor: number + rssi: number +} + +export interface UseBluetoothScanOptions { + /** 信标配置(来自 list-by-area) */ + beaconConfig: InspectionBeaconConfigVO +} + +export function useBluetoothScan(options: UseBluetoothScanOptions): UseBluetoothScanReturn { + const { beaconConfig } = options + const { uuid, boundBeacons, rssiThreshold, timeoutMs } = beaconConfig + + const status = ref('idle') + const progress = ref(0) + const remainingSeconds = ref(Math.ceil(timeoutMs / 1000)) + const matchedBeacons = ref([]) + const errorMessage = ref('') + + let progressTimer: ReturnType | null = null + let scanTimeout: ReturnType | null = null + let isScanning = false + + /** 是否为微信小程序环境 */ + function isWxMp(): boolean { + // #ifdef MP-WEIXIN + return true + // #endif + return false } - /** - * 初始化蓝牙适配器 - * 必须在任何蓝牙操作前调用 + /** 本地比对:检查扫描到的信标是否匹配 boundBeacons 中任一项 + * 条件:major 相同 && minor 相同 && rssi >= rssiThreshold */ - async init(): Promise { - try { - const [err] = await uni.openBluetoothAdapter(); - if (err) { - if (err.errCode === 10001) { - throw new Error('蓝牙适配器不可用,请检查设备蓝牙是否开启'); - } - throw err; + function matchBeacons( + scannedBeacons: Array<{ major: number, minor: number, rssi: number }> + ): MatchedBeacon[] { + const matched: MatchedBeacon[] = [] + for (const scanned of scannedBeacons) { + const isBound = boundBeacons.some( + bound => bound.major === scanned.major && bound.minor === scanned.minor, + ) + if (isBound && scanned.rssi >= rssiThreshold) { + matched.push({ major: scanned.major, minor: scanned.minor, rssi: scanned.rssi }) } + } + return matched + } - // 监听适配器状态变化 - uni.onBluetoothAdapterStateChange((res) => { - if (!res.available) { - this.adapterState = 'error'; - this.emit('adapterError', '蓝牙适配器已关闭'); - } - }); + /** 小程序真实扫描 */ + async function startRealScan() { + isScanning = true + status.value = 'scanning' + progress.value = 0 + errorMessage.value = '' - this.adapterState = 'ready'; - return true; - } catch (error) { - this.adapterState = 'error'; - console.error('[BleManager] 初始化失败:', error); - return false; + // 检查蓝牙权限 + try { + const setting = await new Promise((resolve, reject) => { + uni.getSetting({ success: resolve, fail: reject }) + }) + + const btAuth = setting.authSetting['scope.bluetooth'] + if (btAuth === false) { + status.value = 'no-permission' + errorMessage.value = '蓝牙权限已被拒绝,请在设置中开启' + return + } + if (btAuth === undefined) { + await new Promise((resolve, reject) => { + uni.authorize({ + scope: 'scope.bluetooth', + success: () => resolve(), + fail: () => reject(new Error('蓝牙权限被拒绝')), + }) + }) + } + } catch { + status.value = 'no-permission' + errorMessage.value = '需要蓝牙权限才能进行定位验证' + return + } + + // 监听 Beacon 更新 + uni.onBeaconUpdate(handleBeaconUpdate as any) + + // 启动 Beacon 发现 + try { + await new Promise((resolve, reject) => { + uni.startBeaconDiscovery({ + uuids: [uuid], + success: () => resolve(), + fail: (err: any) => reject(new Error(err.errMsg || '启动蓝牙扫描失败')), + }) + }) + } catch (err: any) { + isScanning = false + errorMessage.value = err.message || '启动蓝牙扫描失败' + status.value = 'failed' + return + } + + // 启动进度条 + startProgress() + + // 超时处理 + scanTimeout = setTimeout(() => { + onScanFailed('定位超时,未能验证位置') + }, timeoutMs) + } + + /** 处理 Beacon 更新(仅小程序) */ + function handleBeaconUpdate( + res: { beacons: Array<{ uuid: string, major: number, minor: number, rssi: number }> } + ) { + if (!isScanning) return + + const matched = matchBeacons(res.beacons) + if (matched.length > 0) { + onScanSuccess(matched) } } - /** - * 扫描附近的工牌设备 - * @param timeout 扫描超时时间(ms),默认10秒 - * @param filterName 设备名称过滤(可选) - */ - async scanDevices(timeout: number = 10000, filterName?: string): Promise { - if (this.adapterState !== 'ready') { - throw new Error('蓝牙适配器未就绪'); - } - - const devices: BLEDevice[] = []; - const discoveredMacs = new Set(); - - return new Promise((resolve, reject) => { - // 开始扫描 - uni.startBluetoothDevicesDiscovery({ - allowDuplicatesKey: false, - interval: 500, - success: () => { - this.adapterState = 'scanning'; - }, - fail: reject - }); - - // 监听发现设备 - uni.onBluetoothDeviceFound((res) => { - res.devices.forEach(device => { - // 过滤重复设备 - if (discoveredMacs.has(device.deviceId)) return; - - // 名称过滤 - if (filterName && !device.name?.includes(filterName)) return; - - // 只收集信号强度足够的设备(-85dBm以内) - if (device.RSSI && device.RSSI > -85) { - discoveredMacs.add(device.deviceId); - devices.push({ - deviceId: device.deviceId, - name: device.name || 'Unknown', - rssi: device.RSSI, - advertisData: device.advertisData - }); - } - }); - }); - - // 超时后停止扫描并返回结果 - setTimeout(() => { - uni.stopBluetoothDevicesDiscovery(); - this.adapterState = 'ready'; - resolve(devices); - }, timeout); - }); + return { + status, + progress, + remainingSeconds, + matchedBeacons, + errorMessage, + startScan: isWxMp() ? startRealScan : startMockScan, + stopScan, + cleanup, } } ``` -### 2.2 设备连接与 GATT 通信 +### 2.2 蓝牙验证组件 -```typescript -// services/ble/BleManager.ts (续) +```vue + + - this.connectedDevice = { - deviceId, - serviceId: targetService.uuid, - characteristics: characteristics.characteristics - }; + ``` --- -## 三、信标感应与到岗打卡机制 +## 三、信标感应与到岗打卡流程 -### 3.1 打卡流程时序 +### 3.1 巡检打卡流程时序 ```mermaid sequenceDiagram participant M as 移动端(UniApp) - participant B as 智能工牌(BADGE) - participant BE as 蓝牙信标(BEACON) participant API as 后端API + participant BLE as 蓝牙信标 - Note over M,API: 工单状态: CONFIRMED(已确认) + Note over M,API: 巡检前必须验证位置 - M->>API: 启动打卡流程
POST /ops/work-order/{id}/arrival-check - API-->>M: 返回该区域绑定的信标MAC列表 + M->>API: GET /ops/inspection/template/list-by-area
{areaId} + API-->>M: 返回信标配置 {uuid, boundBeacons, rssiThreshold, timeoutMs} - M->>B: 发送指令 0x03
启动信标扫描(30秒) - activate B + M->>BLE: 启动 iBeacon 扫描 + activate BLE - B->>BE: 扫描周围BLE广播 - BE-->>B: 广播帧(MAC + RSSI) + BLE-->>M: 广播帧 {uuid, major, minor, rssi} - B->>M: Notify 扫描结果(特征值 FFF4) - M->>M: 校验MAC地址白名单
计算信号强度阈值 + M->>M: 本地比对 major/minor
校验 rssi >= rssiThreshold alt 检测到有效信标 - M->>API: 提交打卡
POST /ops/work-order/{id}/arrival
{beaconMac, rssi, timestamp} - API->>API: 校验信标归属
校验地理围栏 - API-->>M: 状态流转: ARRIVED - M->>B: 发送指令 0x02
LED闪烁2次(成功提示) + M->>M: 状态转为 success + M->>API: POST /ops/inspection/submit
{isLocationException: 0} + API-->>M: 巡检记录创建成功 else 未检测到有效信标 - M->>M: 提示"未在指定区域" - M->>B: 发送指令 0x01
震动3次(失败提示) + M->>M: 超时后状态转为 failed + M->>API: POST /ops/inspection/submit
{isLocationException: 1} + API-->>M: 记录异常位置巡检 end - deactivate B + deactivate BLE ``` -### 3.2 打卡校验逻辑 (`verifyLocation`) +### 3.2 巡检提交接口 ```typescript -// services/ops/WorkOrderService.ts +// api/ops/inspection/index.ts -class WorkOrderService { - /** - * 执行到岗打卡 - * 核心约束:必须检测到指定区域的蓝牙信标 - */ - async checkArrival(workOrderId: string): Promise { - try { - // 1. 获取工单详情和区域信标列表 - const [orderDetail, beaconList] = await Promise.all([ - this.getWorkOrderDetail(workOrderId), - this.getAreaBeacons(workOrderId) - ]); - - // 2. 前置校验 - if (orderDetail.status !== 'CONFIRMED') { - throw new Error(`当前状态 ${orderDetail.status} 不允许打卡`); - } - - if (!BleManager.isConnected()) { - throw new Error('工牌未连接,请先连接设备'); - } - - // 3. 启动信标扫描 - const scanResult = await this.scanBeaconsWithTimeout(30000); - - // 4. 匹配信标白名单 - const matchedBeacon = this.matchBeacon(scanResult, beaconList); - - if (!matchedBeacon) { - return { - success: false, - code: 'BEACON_NOT_FOUND', - message: '未检测到指定区域的信标设备,请确认已到达现场' - }; - } - - // 5. 信号强度校验(防止远距离作弊) - if (matchedBeacon.rssi < -75) { - return { - success: false, - code: 'SIGNAL_TOO_WEAK', - message: '信号强度不足,请靠近信标设备后重试', - data: { rssi: matchedBeacon.rssi } - }; - } - - // 6. 提交后端校验 - const result = await http.post(`/ops/work-order/${workOrderId}/arrival`, { - beaconMac: matchedBeacon.macAddress, - rssi: matchedBeacon.rssi, - scanTime: scanResult.timestamp, - detectedBeacons: scanResult.beacons.length - }); - - // 7. 成功反馈 - if (result.success) { - // 工牌 LED 闪烁提示 - await BleManager.sendCommand(0x02, 2); - - // 本地状态更新 - this.updateLocalOrderStatus(workOrderId, 'ARRIVED'); - } - - return result; - } catch (error) { - console.error('[WorkOrderService] 打卡失败:', error); - throw error; - } - } - - /** - * 扫描信标(带超时控制) - */ - private async scanBeaconsWithTimeout(timeout: number): Promise { - return new Promise((resolve, reject) => { - const timer = setTimeout(() => { - reject(new Error('信标扫描超时')); - }, timeout); - - const onScanResult = (data: BeaconScanResult) => { - clearTimeout(timer); - BleManager.off('beaconScan', onScanResult); - resolve(data); - }; - - BleManager.on('beaconScan', onScanResult); - - // 触发工牌开始扫描 - BleManager.sendCommand(0x03, Math.floor(timeout / 1000)); - }); - } - - /** - * 匹配信标白名单 - */ - private matchBeacon( - scanResult: BeaconScanResult, - whitelist: AreaBeacon[] - ): DetectedBeacon | null { - const whitelistMacs = whitelist.map(b => - b.macAddress.replace(/:/g, '').toUpperCase() - ); - - // 按信号强度排序,优先匹配信号最强的 - const sortedBeacons = scanResult.beacons - .filter(b => whitelistMacs.includes(b.macAddress.toUpperCase())) - .sort((a, b) => b.rssi - a.rssi); - - return sortedBeacons[0] || null; - } +/** 提交巡检请求 */ +export interface InspectionSubmitReqVO { + areaId: number + /** 0=正常 1=异常 */ + isLocationException: number + remark: string + tags?: string[] + photos?: string[] + items: { + templateId: number + isPassed: boolean + remark: string + tags?: string[] + }[] } -export default new WorkOrderService(); +/** 提交巡检结果 */ +export function submitInspection(data: InspectionSubmitReqVO) { + return http.post('/ops/inspection/submit', data) +} ``` --- ## 四、弱网与离线策略 -### 4.1 离线操作队列 (`OfflineActionQueue`) - -```typescript -// services/offline/OfflineActionQueue.ts - -/** - * 离线操作队列 - * 用于弱网环境下缓存非关键操作,待网络恢复后异步上报 - * - * ⚠️ 注意:工单状态扭转(接单、完工)不支持离线,必须强联网 - */ - -interface OfflineAction { - id: string; - type: 'INSPECTION_RECORD' | 'LOCATION_HEARTBEAT' | 'MATERIAL_CONSUME'; - payload: any; - timestamp: number; - retryCount: number; - maxRetries: number; -} - -class OfflineActionQueue { - private readonly STORAGE_KEY = 'aiot_offline_action_queue'; - private isSyncing = false; - private syncInterval: number | null = null; - - /** - * 初始化队列监听 - * 在 App 启动时调用 - */ - init() { - // 监听网络状态变化 - uni.onNetworkStatusChange((res) => { - if (res.isConnected) { - this.flushQueue(); - } - }); - - // 定期尝试同步(每30秒) - this.syncInterval = setInterval(() => { - this.flushQueue(); - }, 30000); - } - - /** - * 添加离线操作 - */ - async enqueue(action: Omit): Promise { - const queue = this.getQueue(); - const newAction: OfflineAction = { - ...action, - id: this.generateId(), - timestamp: Date.now(), - retryCount: 0 - }; - - queue.push(newAction); - await this.saveQueue(queue); - - console.log(`[OfflineQueue] 操作入队: ${action.type}, 队列长度: ${queue.length}`); - } - - /** - * 刷新队列(网络恢复时调用) - */ - async flushQueue(): Promise { - if (this.isSyncing) return; - - const queue = this.getQueue(); - if (queue.length === 0) return; - - // 检查网络状态 - const [networkErr, networkRes] = await uni.getNetworkType(); - if (networkErr || networkRes.networkType === 'none') return; - - this.isSyncing = true; - const failedActions: OfflineAction[] = []; - - for (const action of queue) { - try { - await this.executeAction(action); - console.log(`[OfflineQueue] 操作同步成功: ${action.id}`); - } catch (error) { - action.retryCount++; - if (action.retryCount < action.maxRetries) { - failedActions.push(action); - } else { - console.error(`[OfflineQueue] 操作重试耗尽: ${action.id}`, error); - // 可在此触发告警或记录到日志服务 - } - } - } - - await this.saveQueue(failedActions); - this.isSyncing = false; - } - - /** - * 执行单个操作 - */ - private async executeAction(action: OfflineAction): Promise { - switch (action.type) { - case 'INSPECTION_RECORD': - await http.post('/ops/inspection/record', action.payload); - break; - case 'LOCATION_HEARTBEAT': - await http.post('/ops/location/heartbeat', action.payload); - break; - case 'MATERIAL_CONSUME': - await http.post('/inventory/consume', action.payload); - break; - default: - throw new Error(`未知操作类型: ${action.type}`); - } - } - - private getQueue(): OfflineAction[] { - try { - const data = uni.getStorageSync(this.STORAGE_KEY); - return data ? JSON.parse(data) : []; - } catch { - return []; - } - } - - private async saveQueue(queue: OfflineAction[]): Promise { - uni.setStorageSync(this.STORAGE_KEY, JSON.stringify(queue)); - } - - private generateId(): string { - return `off_${Date.now()}_${Math.random().toString(36).substr(2, 9)}`; - } -} - -export default new OfflineActionQueue(); -``` - -### 4.2 离线策略决策矩阵 +### 4.1 离线策略决策矩阵 | 操作类型 | 是否支持离线 | 策略 | 原因 | |---------|-------------|------|------| | **工单接单** | ❌ 否 | 强联网 + 分布式锁 | 涉及并发抢单,必须实时校验 | | **工单完工** | ❌ 否 | 强联网 + 照片直传 | 需要实时校验定位、照片完整性 | -| **巡检记录** | ✅ 是 | 离线队列缓存 | 非关键状态扭转,可延迟上报 | -| **位置心跳** | ✅ 是 | 离线队列 + 采样降频 | 允许部分丢失,网络恢复后批量上报 | -| **耗材登记** | ✅ 是 | 离线队列缓存 | 库存扣减可延迟,但需在完工前同步完成 | -| **信标打卡** | ❌ 否 | 强联网 | 必须实时校验信标归属和地理围栏 | +| **巡检记录** | ✅ 是 | 本地缓存后上报 | 非关键状态扭转,可延迟上报 | +| **位置心跳** | ✅ 是 | 采样降频 + 批量上报 | 允许部分丢失 | +| **图片上传** | ⚠️ 弱网优化 | 压缩 + 分片 + 重试 | 弱网时降低质量 | -### 4.3 弱网图片上传策略 +### 4.2 弱网图片上传策略 ```typescript // services/upload/ImageUploadService.ts @@ -774,44 +470,41 @@ class ImageUploadService { * 3. 降级:弱网时降低图片质量 */ async captureAndUpload(options: UploadOptions): Promise { - // 1. 拍照(强制使用相机,禁止相册选择) + // 1. 拍照(强制使用相机) const [err, res] = await uni.chooseImage({ sourceType: ['camera'], - sizeType: ['compressed'], // 先使用压缩 + sizeType: ['compressed'], count: 1 - }); + }) - if (err) throw new Error('拍照失败'); + if (err) throw new Error('拍照失败') - const tempFilePath = res.tempFilePaths[0]; + const tempFilePath = res.tempFilePaths[0] // 2. 获取网络类型,决定压缩策略 - const [, networkRes] = await uni.getNetworkType(); - const isWeakNetwork = ['2g', '3g'].includes(networkRes.networkType); + const [, networkRes] = await uni.getNetworkType() + const isWeakNetwork = ['2g', '3g'].includes(networkRes.networkType) // 3. 二次压缩(弱网时更激进) const compressedPath = await this.compressImage(tempFilePath, { quality: isWeakNetwork ? 60 : 80, maxWidth: 1280, maxHeight: 1280 - }); + }) - // 4. 上传 + // 4. 上传(弱网时增加重试次数和超时) const fileUrl = await this.uploadWithRetry(compressedPath, { maxRetries: isWeakNetwork ? 5 : 3, timeout: isWeakNetwork ? 60000 : 30000 - }); + }) // 5. 清理临时文件 - uni.removeSavedFile({ filePath: tempFilePath }); - uni.removeSavedFile({ filePath: compressedPath }); + uni.removeSavedFile({ filePath: tempFilePath }) + uni.removeSavedFile({ filePath: compressedPath }) - return fileUrl; + return fileUrl } - /** - * 图片压缩 - */ private async compressImage( src: string, options: { quality: number; maxWidth: number; maxHeight: number } @@ -821,157 +514,159 @@ class ImageUploadService { quality: options.quality, compressedWidth: options.maxWidth, compressedHeight: options.maxHeight - }); - - if (err) throw err; - return res.tempFilePath; + }) + if (err) throw err + return res.tempFilePath } - /** - * 带重试的上传 - */ private async uploadWithRetry( filePath: string, options: { maxRetries: number; timeout: number } ): Promise { - let lastError: Error | null = null; + let lastError: Error | null = null for (let i = 0; i < options.maxRetries; i++) { try { - const result = await this.doUpload(filePath, options.timeout); - return result; + return await this.doUpload(filePath, options.timeout) } catch (error) { - lastError = error as Error; - + lastError = error as Error // 指数退避 - const delay = Math.min(1000 * Math.pow(2, i), 10000); - await new Promise(r => setTimeout(r, delay)); + const delay = Math.min(1000 * Math.pow(2, i), 10000) + await new Promise(r => setTimeout(r, delay)) } } - throw lastError || new Error('上传失败,已重试' + options.maxRetries + '次'); - } - - private async doUpload(filePath: string, timeout: number): Promise { - return new Promise((resolve, reject) => { - const uploadTask = uni.uploadFile({ - url: `${API_BASE}/oss/upload`, - filePath, - name: 'file', - timeout, - success: (res) => { - if (res.statusCode === 200) { - const data = JSON.parse(res.data); - resolve(data.url); - } else { - reject(new Error(`上传失败: ${res.statusCode}`)); - } - }, - fail: reject - }); - }); + throw lastError || new Error('上传失败') } } +``` -export default new ImageUploadService(); +### 4.3 HTTP 请求封装(含 Token 刷新) + +```typescript +// http/http.ts + +let refreshing = false +let taskQueue: (() => void)[] = [] + +export function http(options: CustomRequestOptions) { + return new Promise((resolve, reject) => { + uni.request({ + ...options, + success: async (res) => { + let responseData = res.data as IResponse + + // 解密响应数据(如启用加密) + const encryptHeader = ApiEncrypt.getEncryptHeader() + const isEncryptResponse = res.header[encryptHeader] === 'true' + if (isEncryptResponse && typeof responseData === 'string') { + responseData = ApiEncrypt.decryptResponse(responseData) + } + + const { code } = responseData + const isTokenExpired = res.statusCode === 401 || code === 401 + + if (isTokenExpired) { + const tokenStore = useTokenStore() + if (!isDoubleTokenMode) { + tokenStore.logout() + toLoginPage() + return reject(res) + } + + // 无感刷新 Token + const { refreshToken } = tokenStore.tokenInfo || {} + if (refreshToken) { + taskQueue.push(() => resolve(http(options))) + } + + if (refreshToken && !refreshing) { + refreshing = true + try { + await tokenStore.refreshToken() + refreshing = false + taskQueue.forEach(task => task()) + } catch (refreshErr) { + refreshing = false + tokenStore.logout() + toLoginPage() + } finally { + taskQueue = [] + } + } + return reject(res) + } + + // 成功处理 + if (res.statusCode >= 200 && res.statusCode < 300) { + if (code !== ResultEnum.Success0 && code !== ResultEnum.Success200) { + uni.showToast({ + title: responseData.msg || '请求错误', + icon: 'none', + }) + return reject(responseData) + } + return resolve(responseData.data) + } + + reject(res) + }, + fail(err) { + uni.showToast({ + icon: 'none', + title: '网络错误,换个网络试试', + }) + reject(err) + }, + }) + }) +} ``` --- -## 五、基础数据缓存策略 (`Stale-while-revalidate`) +## 五、常见坑点与调试技巧 + +### 5.1 蓝牙调试 ```typescript -// services/cache/DataCacheService.ts +// 开启蓝牙日志(开发环境) +uni.onBeaconUpdate((res) => { + console.log('[BLE] Beacon 更新:', res.beacons.map(b => ({ + uuid: b.uuid, + major: b.major, + minor: b.minor, + rssi: b.rssi + }))) +}) -/** - * 基础数据缓存服务 - * 采用 Stale-while-revalidate 策略: - * 1. 优先使用本地缓存渲染界面(无等待) - * 2. 后台静默请求最新数据 - * 3. 如有更新,替换缓存并触发界面刷新 - */ +// 监听蓝牙状态变化 +uni.onBluetoothAdapterStateChange((res) => { + console.log('[BLE] 适配器状态:', res.available, res.discovering) +}) +``` -interface CacheEntry { - data: T; - timestamp: number; - version: string; -} +### 5.2 常见问题速查 -class DataCacheService { - private readonly DEFAULT_TTL = 24 * 60 * 60 * 1000; // 24小时 +| 问题 | 原因 | 解决方案 | +|-----|------|---------| +| 扫描不到信标 | 未授权定位权限 | iOS/Android 均需定位权限才能扫描 BLE | +| 信标扫描为空 | UUID 不匹配 | 检查 uuid 是否与 boundBeacons 对应 | +| 定位验证失败 | RSSI 低于阈值 | 靠近信标,确保信号强度 >= rssiThreshold | +| 图片上传超时 | 弱网 + 未压缩 | 检查压缩参数,或切换网络重试 | +| Token 刷新失败 | refreshToken 过期 | 重新登录 | +| 页面返回白屏 | 内存泄漏 | 检查 onUnload 是否清理资源 | - /** - * 获取缓存数据(带后台刷新) - */ - async getWithRevalidate( - key: string, - fetcher: () => Promise<{ data: T; version: string }>, - options: { ttl?: number; immediate?: boolean } = {} - ): Promise { - const ttl = options.ttl || this.DEFAULT_TTL; - const cached = this.getFromStorage(key); +### 5.3 调试工具 - // 如果有有效缓存,先返回缓存 - if (cached && Date.now() - cached.timestamp < ttl) { - // 后台刷新(不阻塞) - if (!options.immediate) { - this.refreshInBackground(key, fetcher, cached.version); - } - return cached.data; - } +```typescript +// pages/scan/bluetooth-debug/index.vue +// 蓝牙调试页面(仅 MP-WEIXIN) - // 无缓存或已过期,强制刷新 - const result = await fetcher(); - this.saveToStorage(key, result.data, result.version); - return result.data; - } - - /** - * 后台静默刷新 - */ - private async refreshInBackground( - key: string, - fetcher: () => Promise<{ data: T; version: string }>, - currentVersion: string - ): Promise { - try { - const result = await fetcher(); - - // 版本变化才更新 - if (result.version !== currentVersion) { - this.saveToStorage(key, result.data, result.version); - - // 触发全局事件,通知界面刷新 - uni.$emit('cache:updated', { key, data: result.data }); - - console.log(`[DataCache] ${key} 已更新到版本 ${result.version}`); - } - } catch (error) { - console.warn(`[DataCache] ${key} 后台刷新失败:`, error); - // 后台刷新失败不影响当前显示 - } - } - - private getFromStorage(key: string): CacheEntry | null { - try { - const data = uni.getStorageSync(`cache_${key}`); - return data ? JSON.parse(data) : null; - } catch { - return null; - } - } - - private saveToStorage(key: string, data: T, version: string): void { - const entry: CacheEntry = { - data, - timestamp: Date.now(), - version - }; - uni.setStorageSync(`cache_${key}`, JSON.stringify(entry)); - } -} - -export default new DataCacheService(); +// 功能: +// 1. 扫描周围所有 iBeacon +// 2. 显示 UUID/Major/Minor/RSSI +// 3. 测试特定信标匹配逻辑 ``` --- @@ -982,10 +677,10 @@ export default new DataCacheService(); | 红线项 | 说明 | 后果 | |-------|------|------| -| **禁止前端判定打卡成功** | 信标打卡必须由后端 `verifyLocation` 校验 | 作弊风险 | -| **必须启用 Notify** | 按键事件、信标扫描结果通过 Notify 接收 | 事件丢失 | -| **MTU 分包** | BLE 单次传输限制 20 字节,长帧必须分包 | 指令截断 | -| **连接超时处理** | 工牌可能进入休眠,连接失败需引导用户唤醒 | 用户体验差 | +| **必须申请权限** | 蓝牙 + 定位权限缺一不可 | 扫描失败 | +| **本地比对信标** | major/minor/rssi 三重校验 | 定位作弊 | +| **超时必处理** | 扫描超时后必须停止 discovery | 耗电/内存泄漏 | +| **Mock 降级** | H5/App 环境使用 Mock 数据 | 开发调试 | ### 6.2 弱网策略红线 @@ -994,44 +689,15 @@ export default new DataCacheService(); | **状态扭转不强联** | 接单、完工必须实时联网 | 数据不一致 | | **照片必须压缩** | 原图上传在弱网下几乎必然失败 | 操作卡死 | | **按钮必须防抖** | 提交类按钮点击后立即 disabled | 重复提交 | -| **离线队列容量限制** | 防止无限堆积导致存储溢出 | 应用崩溃 | +| **Token 无感刷新** | 401 时自动刷新,对用户透明 | 体验好 | ### 6.3 相关代码入口 | 模块 | 文件路径 | 核心类/函数 | |-----|---------|------------| -| 蓝牙管理 | `services/ble/BleManager.ts` | `BleManager` | -| 工单服务 | `services/ops/WorkOrderService.ts` | `checkArrival()` | -| 离线队列 | `services/offline/OfflineActionQueue.ts` | `enqueue()`, `flushQueue()` | -| 图片上传 | `services/upload/ImageUploadService.ts` | `captureAndUpload()` | -| 数据缓存 | `services/cache/DataCacheService.ts` | `getWithRevalidate()` | - ---- - -## 七、调试与排障 - -### 7.1 蓝牙调试 - -```typescript -// 开启蓝牙日志(开发环境) -uni.setBLEMTU({ deviceId, mtu: 512 }); // 尝试协商更大 MTU - -// 监听所有蓝牙事件 -uni.onBluetoothDeviceFound((res) => { - console.log('[BLE] 发现设备:', res.devices.map(d => ({ - name: d.name, - deviceId: d.deviceId, - RSSI: d.RSSI - }))); -}); -``` - -### 7.2 常见问题 - -| 问题 | 原因 | 解决方案 | -|-----|------|---------| -| 扫描不到工牌 | 工牌休眠/电量耗尽 | 按工牌按键唤醒,或充电后重试 | -| 连接后无响应 | 服务发现失败 | 重启蓝牙适配器,重新连接 | -| 信标扫描为空 | 未授权定位权限 | iOS/Android 均需定位权限才能扫描 BLE | -| 打卡提示"未在区域" | 信标 MAC 不匹配 | 检查信标是否绑定到正确区域 | -| 图片上传超时 | 弱网 + 未压缩 | 检查压缩参数,或切换网络重试 | +| 蓝牙扫描 Hook | `pages-ops/inspection/composables/use-bluetooth-scan.ts` | `useBluetoothScan()` | +| 蓝牙验证组件 | `pages-ops/inspection/components/bluetooth-verify.vue` | `BluetoothVerify` | +| 巡检 API | `api/ops/inspection/index.ts` | `getInspectionForm()`, `submitInspection()` | +| 工牌 API | `api/ops/cleaning/index.ts` | `getBadgeStatusList()`, `sendDeviceNotify()` | +| HTTP 封装 | `http/http.ts` | `http()`, Token 刷新逻辑 | +| 图片上传 | `services/upload/ImageUploadService.ts` | `captureAndUpload()` | \ No newline at end of file diff --git a/开发者文档/08-附录/01-术语表.md b/开发者文档/08-附录/01-术语表.md new file mode 100644 index 0000000..df79c0c --- /dev/null +++ b/开发者文档/08-附录/01-术语表.md @@ -0,0 +1,200 @@ +# 01-术语表 + +本文档汇总 AIOT 系统中使用的专业术语,按领域分类整理。 + +--- + +## 一、IoT 设备术语 + +### 1.1 工牌(Badge) + +| 术语 | 英文 | 说明 | +|-----|------|------| +| 智能工牌 | Smart Badge | 保洁/安保人员佩戴的 BLE 设备,用于接收工单通知、上报位置 | +| 工牌状态 | Badge Status | IDLE(空闲) / BUSY(忙碌) / PAUSED(暂停) / OFFLINE(离线) | +| 工牌通知 | Badge Notify | 向工牌发送震动/语音通知 | +| 心跳 | Heartbeat | 工牌定期上报在线状态 | +| 电量 | Battery Level | 工牌剩余电量百分比 (0-100) | +| RSSI | Received Signal Strength Indicator | 信号强度指示,单位 dBm | + +### 1.2 信标(Beacon) + +| 术语 | 英文 | 说明 | +|-----|------|------| +| 蓝牙信标 | Bluetooth Beacon | 固定部署的 BLE 广播设备,用于定位 | +| iBeacon | Apple iBeacon | Apple 制定的 BLE 广播协议 | +| UUID | Universally Unique Identifier | 信标唯一标识,16字节 | +| Major | Major ID | 主标识,通常对应区域ID | +| Minor | Minor ID | 次标识,通常对应信标ID | +| 信号阈值 | RSSI Threshold | 最小可接收信号强度,如 -75dBm | + +### 1.3 设备状态 + +| 术语 | 英文 | 说明 | +|-----|------|------| +| 未激活 | Inactive | 设备未激活状态 | +| 在线 | Online | 设备正常连接 | +| 离线 | Offline | 设备断开连接 | + +--- + +## 二、工单业务术语 + +### 2.1 工单类型 + +| 术语 | 英文 | 说明 | +|-----|------|------| +| 保洁工单 | Cleaning Order | 保洁作业任务 | +| 安保工单 | Security Order | 安保处理任务 | +| 维修工单 | Repair Order | 设备维修任务 | +| 客服工单 | Service Order | 客服服务任务 | + +### 2.2 工单状态(状态机) + +```mermaid +stateDiagram-v2 + [*] --> PENDING: 创建 + PENDING --> QUEUED: 进入队列 + QUEUED --> DISPATCHED: 推送到工牌 + DISPATCHED --> CONFIRMED: 保洁员确认 + CONFIRMED --> ARRIVED: 到达现场 + ARRIVED --> COMPLETED: 完成作业 + ARRIVED --> PAUSED: 暂停 + PAUSED --> ARRIVED: 恢复 + PENDING --> CANCELLED: 取消 + QUEUED --> CANCELLED: 取消 + DISPATCHED --> CANCELLED: 取消 + CONFIRMED --> CANCELLED: 取消 + ARRIVED --> CANCELLED: 取消 +``` + +| 状态 | 英文 | 说明 | +|-----|------|------| +| 待分配 | PENDING | 工单已创建,等待分配 | +| 排队中 | QUEUED | 已推荐保洁员,在队列中等待 | +| 已推送 | DISPATCHED | 已推送到工牌,等待确认 | +| 已确认 | CONFIRMED | 保洁员按下确认按钮 | +| 已到岗 | ARRIVED | 到达现场,开始作业 | +| 进行中 | IN_PROGRESS | 作业进行中 | +| 已暂停 | PAUSED | 临时暂停 | +| 已完成 | COMPLETED | 作业完成 | +| 已取消 | CANCELLED | 工单取消 | + +### 2.3 优先级 + +| 优先级 | 英文 | 说明 | +|-------|------|------| +| P0 紧急 | P0 Urgent | 最高优先级,可打断当前任务 | +| P1 重要 | P1 High | 高优先级,优先处理 | +| P2 普通 | P2 Normal | 普通优先级 | +| P3 低优 | P3 Low | 低优先级 | + +### 2.4 触发来源 + +| 来源 | 英文 | 说明 | +|-----|------|------| +| 蓝牙信标 | IOT_BEACON | 信标触发(如厕所溢水) | +| 客流阈值 | IOT_TRAFFIC | 客流统计触发 | +| 视频告警 | VIDEO_ALARM | AI 视频分析告警 | +| 门禁告警 | ACCESS_ALARM | 门禁系统告警 | +| 巡更告警 | PATROL_ALARM | 巡更异常告警 | +| 紧急按钮 | PANIC_BUTTON | 紧急求助按钮 | +| 手动创建 | MANUAL | 人工创建工单 | + +--- + +## 三、巡检术语 + +### 3.1 巡检要素 + +| 术语 | 英文 | 说明 | +|-----|------|------| +| 巡检区域 | Inspection Area | 需要巡检的物理区域 | +| 巡检模板 | Inspection Template | 该区域检查项的配置 | +| 检查项 | Inspection Item | 具体的检查内容 | +| 巡检记录 | Inspection Record | 一次巡检的结果记录 | + +### 3.2 巡检结果 + +| 术语 | 英文 | 说明 | +|-----|------|------| +| 合格 | Passed | 检查项通过 | +| 不合格 | Failed | 检查项未通过 | +| 位置异常 | Location Exception | 未在指定区域完成巡检 | +| 归属判定 | Attribution | 异常原因:个人责任/突发状况/正常 | + +--- + +## 四、人员术语 + +### 4.1 角色 + +| 术语 | 英文 | 说明 | +|-----|------|------| +| 保洁员 | Cleaner | 执行保洁任务的人员 | +| 安保员 | Security Guard | 执行安保任务的人员 | +| 维修员 | Repairman | 执行维修任务的人员 | +| 巡检员 | Inspector | 执行巡检任务的人员 | +| 调度员 | Dispatcher | 负责工单分配的人员 | + +### 4.2 人员状态 + +| 术语 | 英文 | 说明 | +|-----|------|------| +| 空闲 | IDLE | 可接受新任务 | +| 忙碌 | BUSY | 正在执行任务 | +| 暂停 | PAUSED | 临时离开 | +| 离线 | OFFLINE | 不在线 | + +--- + +## 五、区域术语 + +| 术语 | 英文 | 说明 | +|-----|------|------| +| 区域 | Area | 物理空间划分 | +| 父区域 | Parent Area | 上级区域 | +| 子区域 | Child Area | 下级区域 | +| 功能类型 | Function Type | 区域功能:厕所/电梯/大堂等 | +| 地理围栏 | Geo-fence | 虚拟边界,用于位置校验 | + +--- + +## 六、移动端术语 + +### 6.1 小程序 + +| 术语 | 英文 | 说明 | +|-----|------|------| +| 主包 | Main Package | 小程序主包,2MB 限制 | +| 分包 | SubPackage | 业务分包,2MB 限制 | +| 预加载 | Preload | 提前加载分包资源 | +| TabBar | Tab Bar | 底部导航栏 | + +### 6.2 蓝牙 + +| 术语 | 英文 | 说明 | +|-----|------|------| +| BLE | Bluetooth Low Energy | 低功耗蓝牙 | +| GATT | Generic Attribute Profile | 通用属性配置文件 | +| MTU | Maximum Transmission Unit | 最大传输单元,默认20字节 | +| Notify | Notification | BLE 通知机制 | +| 广播 | Advertising | BLE 设备广播数据 | +| 扫描 | Scanning | 搜索周围 BLE 设备 | + +--- + +## 七、缩写对照 + +| 缩写 | 全称 | 中文 | +|-----|------|------| +| AIOT | Artificial Intelligence of Things | 智联网 | +| BLE | Bluetooth Low Energy | 低功耗蓝牙 | +| RSSI | Received Signal Strength Indicator | 接收信号强度指示 | +| UUID | Universally Unique Identifier | 通用唯一标识符 | +| API | Application Programming Interface | 应用程序接口 | +| HTTP | HyperText Transfer Protocol | 超文本传输协议 | +| JSON | JavaScript Object Notation | JavaScript 对象表示法 | +| DOM | Document Object Model | 文档对象模型 | +| Pinia | - | Vue 状态管理库 | +| UniApp | - | 跨端开发框架 | diff --git a/开发者文档/08-附录/02-错误码清单.md b/开发者文档/08-附录/02-错误码清单.md new file mode 100644 index 0000000..ea539ed --- /dev/null +++ b/开发者文档/08-附录/02-错误码清单.md @@ -0,0 +1,242 @@ +# 02-错误码清单 + +本文档汇总 AIOT 系统中使用的错误码,包括后端统一错误码和前端/移动端特定错误。 + +--- + +## 一、后端统一错误码 + +### 1.1 全局错误码(0-999) + +| 错误码 | 错误信息 | 说明 | +|-------|---------|------| +| 0 | 成功 | 请求成功 | +| 400 | 请求参数不正确 | 参数校验失败 | +| 401 | 账号未登录 | Token 过期或无效 | +| 403 | 没有该操作权限 | 权限不足 | +| 404 | 请求未找到 | 资源不存在 | +| 405 | 请求方法不正确 | HTTP 方法错误 | +| 423 | 请求失败,请稍后重试 | 并发请求锁定 | +| 429 | 请求过于频繁,请稍后重试 | 限流触发 | +| 500 | 系统异常 | 服务器内部错误 | +| 501 | 功能未实现/未开启 | 功能未开放 | +| 502 | 错误的配置项 | 配置错误 | +| 900 | 重复请求,请稍后重试 | 重复提交 | +| 901 | 演示模式,禁止写操作 | Demo 模式限制 | +| 999 | 未知错误 | 未分类错误 | + +### 1.2 OPS 模块错误码(1-020-xxx-xxx) + +#### 区域管理(1-020-001-xxx) + +| 错误码 | 错误信息 | 说明 | +|-------|---------|------| +| 1020001000 | 区域不存在 | AREA_NOT_FOUND | +| 1020001001 | 该区域下存在子区域,请先处理子区域 | AREA_HAS_CHILDREN | +| 1020001002 | 该区域已绑定设备,请先解除绑定 | AREA_HAS_DEVICES | +| 1020001003 | 不能将父级设置为自己或子孙节点 | AREA_PARENT_LOOP | +| 1020001004 | 区域编码已存在 | AREA_CODE_EXISTS | + +#### 区域设备关联(1-020-002-xxx) + +| 错误码 | 错误信息 | 说明 | +|-------|---------|------| +| 1020002000 | 设备不存在 | DEVICE_NOT_FOUND | +| 1020002001 | 该工牌已绑定至此区域 | DEVICE_ALREADY_BOUND | +| 1020002002 | 该区域已绑定{类型},一个区域只能绑定一个 | DEVICE_TYPE_ALREADY_BOUND | +| 1020002003 | 设备关联关系不存在 | DEVICE_RELATION_NOT_FOUND | +| 1020002004 | IoT 设备服务不可用,请稍后重试 | IOT_SERVICE_UNAVAILABLE | + +#### 安保工单(1-020-003-xxx) + +| 错误码 | 错误信息 | 说明 | +|-------|---------|------| +| 1020003000 | 工单不存在 | SECURITY_ORDER_NOT_FOUND | +| 1020003001 | 工单类型不匹配,期望安保工单 | SECURITY_ORDER_TYPE_MISMATCH | +| 1020003002 | 该安保人员已绑定到此区域 | SECURITY_AREA_USER_DUPLICATE | +| 1020003003 | 绑定记录不存在 | SECURITY_AREA_USER_NOT_FOUND | +| 1020003004 | 目标安保人员未绑定到工单所属区域或已停用 | SECURITY_ASSIGNEE_NOT_BOUND_TO_AREA | +| 1020003005 | 当前工单状态不允许手动派单,仅 PENDING 状态可操作 | SECURITY_ORDER_STATUS_NOT_ALLOW_DISPATCH | +| 1020003006 | 已完成或已取消的工单不允许升级优先级 | SECURITY_ORDER_PRIORITY_UPGRADE_NOT_ALLOWED | + +#### 巡检模块(1-020-004-xxx) + +| 错误码 | 错误信息 | 说明 | +|-------|---------|------| +| 1020004000 | 巡检模板不存在 | INSPECTION_TEMPLATE_NOT_FOUND | +| 1020004001 | 巡检记录不存在 | INSPECTION_RECORD_NOT_FOUND | +| 1020004002 | 该区域未启用,无法巡检 | INSPECTION_AREA_NOT_ACTIVE | + +--- + +## 二、前端/移动端错误码 + +### 2.1 HTTP 状态码(ResultEnum) + +```typescript +// http/tools/enum.ts + +export enum ResultEnum { + Success0 = 0, // 成功 + Success200 = 200, // 成功 + Error = 400, // 错误 + Unauthorized = 401, // 未授权 + Forbidden = 403, // 禁止访问 + NotFound = 404, // 未找到 + MethodNotAllowed = 405, // 方法不允许 + RequestTimeout = 408, // 请求超时 + InternalServerError = 500, // 服务器错误 + NotImplemented = 501, // 未实现 + BadGateway = 502, // 网关错误 + ServiceUnavailable = 503, // 服务不可用 + GatewayTimeout = 504, // 网关超时 + HttpVersionNotSupported = 505, // HTTP版本不支持 +} +``` + +### 2.2 蓝牙扫描状态(ScanStatus) + +```typescript +// pages-ops/inspection/composables/use-bluetooth-scan.ts + +export type ScanStatus = + | 'idle' // 空闲 + | 'scanning' // 扫描中 + | 'success' // 定位成功 + | 'failed' // 定位失败 + | 'no-permission' // 缺少权限 +``` + +### 2.3 蓝牙扫描错误信息 + +| 状态 | 错误信息 | 处理建议 | +|-----|---------|---------| +| no-permission | 蓝牙权限已被拒绝,请在设置中开启 | 引导用户打开设置 | +| no-permission | 需要蓝牙权限才能进行定位验证 | 申请蓝牙权限 | +| failed | 启动蓝牙扫描失败 | 检查蓝牙是否开启 | +| failed | 定位超时,未能验证位置 | 靠近信标重试 | + +### 2.4 网络错误提示 + +```typescript +// http/tools/enum.ts + +export function ShowMessage(status: number | string): string { + switch (status) { + case 400: return '请求错误(400),请检查网络或联系管理员!' + case 401: return '未授权,请重新登录(401),请检查网络或联系管理员!' + case 403: return '拒绝访问(403),请检查网络或联系管理员!' + case 404: return '请求出错(404),请检查网络或联系管理员!' + case 408: return '请求超时(408),请检查网络或联系管理员!' + case 500: return '服务器错误(500),请检查网络或联系管理员!' + case 501: return '服务未实现(501),请检查网络或联系管理员!' + case 502: return '网络错误(502),请检查网络或联系管理员!' + case 503: return '服务不可用(503),请检查网络或联系管理员!' + case 504: return '网络超时(504),请检查网络或联系管理员!' + case 505: return 'HTTP版本不受支持(505),请检查网络或联系管理员!' + default: return `连接出错(${status})!,请检查网络或联系管理员!` + } +} +``` + +--- + +## 三、错误码处理规范 + +### 3.1 后端错误处理 + +```java +// Java 后端统一返回格式 +public class CommonResult { + private Integer code; // 错误码 + private String msg; // 错误信息 + private T data; // 数据 + + public static CommonResult success(T data) { + return new CommonResult<>(0, "成功", data); + } + + public static CommonResult error(ErrorCode errorCode) { + return new CommonResult<>(errorCode.getCode(), errorCode.getMsg(), null); + } +} +``` + +### 3.2 前端错误处理 + +```typescript +// http/http.ts 统一错误处理 + +success: async (res) => { + const { code, msg } = responseData + + // Token 过期处理 + if (res.statusCode === 401 || code === 401) { + const tokenStore = useTokenStore() + if (!isDoubleTokenMode) { + tokenStore.logout() + toLoginPage() + return reject(res) + } + // 无感刷新 Token... + } + + // 业务错误处理 + if (code !== ResultEnum.Success0 && code !== ResultEnum.Success200) { + uni.showToast({ + title: msg || '请求错误', + icon: 'none', + }) + return reject(responseData) + } + + return resolve(responseData.data) +}, + +fail(err) { + uni.showToast({ + icon: 'none', + title: '网络错误,换个网络试试', + }) + reject(err) +} +``` + +### 3.3 错误码对照表 + +| 场景 | 后端错误码 | 前端处理 | +|-----|-----------|---------| +| 参数错误 | 400 | Toast 提示具体错误 | +| Token 过期 | 401 | 自动刷新或跳转登录 | +| 权限不足 | 403 | Toast 提示无权限 | +| 资源不存在 | 404 | 显示空状态或返回 | +| 服务器错误 | 500 | Toast 提示系统异常 | +| 请求频繁 | 429 | Toast 提示稍后重试 | +| 蓝牙权限拒绝 | - | 引导打开系统设置 | +| 定位超时 | - | 提示靠近信标重试 | +| 网络断开 | - | Toast 提示网络错误 | + +--- + +## 四、错误码速查 + +### 4.1 按模块速查 + +| 模块 | 错误码范围 | 文件 | +|-----|-----------|------| +| 全局 | 0-999 | GlobalErrorCodeConstants.java | +| 区域 | 1-020-001-xxx | ErrorCodeConstants.java | +| 设备 | 1-020-002-xxx | ErrorCodeConstants.java | +| 安保 | 1-020-003-xxx | ErrorCodeConstants.java | +| 巡检 | 1-020-004-xxx | ErrorCodeConstants.java | + +### 4.2 按场景速查 + +| 场景 | 常见错误码 | 解决方案 | +|-----|-----------|---------| +| 登录失败 | 401 | 检查账号密码,重新登录 | +| 派单失败 | 1020003005 | 确认工单状态为 PENDING | +| 信标绑定失败 | 1020002001 | 解绑后重新绑定 | +| 巡检提交失败 | 1020004002 | 确认区域已启用 | +| 蓝牙扫描失败 | no-permission | 开启蓝牙和定位权限 | +| 图片上传失败 | 408/504 | 压缩图片后重试 |