From 3f12e34afd3f2209ec6666f5074c38af88eb7f5b Mon Sep 17 00:00:00 2001 From: 17792275749 <812100002@qq.com> Date: Wed, 13 May 2026 16:18:14 +0800 Subject: [PATCH] =?UTF-8?q?[AI=20Generated]:=20feat(*):=20=E6=96=B0?= =?UTF-8?q?=E5=A2=9E=20lefu=20=E8=93=9D=E7=89=99=E6=9C=8D=E5=8A=A1?= =?UTF-8?q?=E5=B1=82=E3=80=81mock=20=E4=B8=9A=E5=8A=A1=E6=95=B0=E6=8D=AE?= =?UTF-8?q?=E5=B1=82=E5=8F=8A=20request=20=E8=AF=B7=E6=B1=82=E5=B0=81?= =?UTF-8?q?=E8=A3=85=EF=BC=8C=E6=8E=A5=E5=85=A5=E5=90=84=E9=A1=B5=E9=9D=A2?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- miniprogram/app.ts | 29 +- miniprogram/lefu/index.ts | 11 + miniprogram/lefu/service.ts | 454 ++++++++++++++++++ miniprogram/lefu/types.ts | 97 ++++ miniprogram/mock/data.mock.ts | 69 +++ miniprogram/mock/home.mock.ts | 40 ++ miniprogram/mock/member.mock.ts | 69 +++ .../pages/connectedDevice/connectedDevice.ts | 124 ++--- .../pages/connectedWifi/connectedWifi.ts | 176 ++++--- miniprogram/pages/data/data.less | 1 + miniprogram/pages/data/data.ts | 196 +++----- miniprogram/pages/data/data.wxml | 2 +- .../equipmentMember/equipmentMember.less | 1 + .../pages/equipmentMember/equipmentMember.ts | 239 +++++---- .../equipmentMember/equipmentMember.wxml | 2 +- miniprogram/pages/home/home.ts | 149 ++---- miniprogram/utils/request/index.ts | 87 ++++ miniprogram/utils/request/types.ts | 16 + 18 files changed, 1222 insertions(+), 540 deletions(-) create mode 100644 miniprogram/lefu/index.ts create mode 100644 miniprogram/lefu/service.ts create mode 100644 miniprogram/lefu/types.ts create mode 100644 miniprogram/mock/data.mock.ts create mode 100644 miniprogram/mock/home.mock.ts create mode 100644 miniprogram/mock/member.mock.ts create mode 100644 miniprogram/utils/request/index.ts create mode 100644 miniprogram/utils/request/types.ts diff --git a/miniprogram/app.ts b/miniprogram/app.ts index 1af73a8..b18f25b 100644 --- a/miniprogram/app.ts +++ b/miniprogram/app.ts @@ -1,18 +1,13 @@ -// app.ts -App({ - globalData: {}, - onLaunch() { - // 展示本地存储能力 - const logs = wx.getStorageSync('logs') || [] - logs.unshift(Date.now()) - wx.setStorageSync('logs', logs) +import { lefuService } from './lefu/index' - // 登录 - wx.login({ - success: res => { - console.log(res.code) - // 发送 res.code 到后台换取 openId, sessionKey, unionId - }, - }) - }, -}) \ No newline at end of file +App({ + globalData: { + /** 当前已连接设备的蓝牙状态,页面可读取 */ + connectState: '' as string, + }, + + onLaunch() { + // 初始化乐福蓝牙 SDK + lefuService.init() + }, +}) diff --git a/miniprogram/lefu/index.ts b/miniprogram/lefu/index.ts new file mode 100644 index 0000000..c182766 --- /dev/null +++ b/miniprogram/lefu/index.ts @@ -0,0 +1,11 @@ +export { lefuService } from './service' +export type { + ScannedDevice, + LeFuWifiItem, + ProgressData, + LockData, + WifiVersion, + LeFuConfig, + DeviceSetting, + DeviceMember, +} from './types' diff --git a/miniprogram/lefu/service.ts b/miniprogram/lefu/service.ts new file mode 100644 index 0000000..fd2d733 --- /dev/null +++ b/miniprogram/lefu/service.ts @@ -0,0 +1,454 @@ +import type { + RawDevice, + LeFuPlugin, + ScannedDevice, + LeFuWifiItem, + ProgressData, + LockData, + WifiVersion, + LeFuConfig, + DeviceSetting, + DeviceMember, +} from './types' + +/** 乐福 SDK 配置 */ +const LEFU_CONFIG: LeFuConfig = { + key: 'lefudc91611833e18d94', + secret: 'eQ50JYimMp0X7uhNLj6D3lTEk4TUUvEG7dvaqKM9t1U=', + domain1: 'http://device.shuziweidao.com:80/gateway', + domain2: 'http://device.shuziweidao.com:80/weight', +} + +/** 支持的设备型号配置列表(5款) */ +const DEVICE_SETTINGS: DeviceSetting[] = [ + { + advLength: 999, calorieStatus: 0, createBy: null, + deviceAccuracyType: 2, deviceCalcuteType: 3, deviceConnectType: 2, + deviceFuncType: 223, deviceName: 'CF568_G', devicePowerType: 3, + deviceProtocolType: 3, deviceType: 1, deviceUnitType: '0,1,11', + id: 65, imgUrl: null, macAddressStart: 6, remark: null, + sign: 'FF', status: 0, uhStatus: 1, updateBy: null, + }, + { + advLength: 999, calorieStatus: 0, createBy: null, + deviceAccuracyType: 2, deviceCalcuteType: 3, deviceConnectType: 2, + deviceFuncType: 223, deviceName: 'YX-B4-568-BW', devicePowerType: 3, + deviceProtocolType: 3, deviceType: 1, deviceUnitType: '0,1,11', + id: 65, imgUrl: null, macAddressStart: 6, remark: null, + sign: 'FF', status: 0, uhStatus: 1, updateBy: null, + }, + { + advLength: 999, calorieStatus: 0, createBy: null, + deviceAccuracyType: 2, deviceCalcuteType: 4, deviceConnectType: 2, + deviceFuncType: 65759, deviceName: 'CF636_G', devicePowerType: 3, + deviceProtocolType: 3, deviceType: 1, deviceUnitType: '0,1,2', + id: 216, imgUrl: null, macAddressStart: 6, remark: null, + sign: 'FF', status: 0, uhStatus: 1, updateBy: null, + }, + { + advLength: 999, calorieStatus: 0, createBy: null, + deviceAccuracyType: 2, deviceCalcuteType: 4, deviceConnectType: 2, + deviceFuncType: 65759, deviceName: 'YX-B8-636-BW', devicePowerType: 3, + deviceProtocolType: 3, deviceType: 1, deviceUnitType: '0,1,2', + id: 216, imgUrl: null, macAddressStart: 6, remark: null, + sign: 'FF', status: 0, uhStatus: 1, updateBy: null, + }, + { + advLength: 999, calorieStatus: 0, createBy: null, + deviceAccuracyType: 2, deviceCalcuteType: 4, deviceConnectType: 2, + deviceFuncType: 223, deviceName: 'CF577', devicePowerType: 3, + deviceProtocolType: 3, deviceType: 1, deviceUnitType: '0,1,11', + id: 51, imgUrl: null, macAddressStart: 6, remark: null, + sign: 'FF', status: 0, uhStatus: 1, updateBy: null, + }, +] + +class LeFuService { + private _plugin: LeFuPlugin | null = null + /** 设备协议对象,devicesModel 事件返回,用于 WiFi 配网等高级操作 */ + private _activeProtocol: any = null + + /** 重连次数 */ + private _reconnectCount = 0 + private readonly _maxReconnect = 3 + private _reconnectTimer: number | null = null + + /** 保活心跳定时器 */ + private _keepAliveTimer: number | null = null + + /** 回调注册(页面通过 on* 方法注册,stopScan 时清空) */ + private _onDevicesListCb: ((devices: ScannedDevice[]) => void) | null = null + private _onConnectStateCb: ((state: string) => void) | null = null + private _onProgressCb: ((data: ProgressData) => void) | null = null + private _onLockedCb: ((data: LockData) => void) | null = null + private _onDisconnectedCb: (() => void) | null = null + + /** 在 app.ts onLaunch 中调用,加载 ppScale-plugin */ + init(): void { + this._plugin = requirePlugin('ppScale-plugin') as LeFuPlugin + } + + /** 获取插件(未初始化时抛出) */ + private get _p(): LeFuPlugin { + if (!this._plugin) throw new Error('[LeFu] 服务未初始化,请先调用 init()') + return this._plugin + } + + /** 乐福配置(供体成分分析等外部使用) */ + get config(): LeFuConfig { + return LEFU_CONFIG + } + + // ─── 回调注册 ───────────────────────────────── + + /** 注册设备列表回调(扫描到设备时触发) */ + onDevicesList(cb: (devices: ScannedDevice[]) => void): void { + this._onDevicesListCb = cb + } + + /** 注册蓝牙连接状态回调 */ + onConnectState(cb: (state: string) => void): void { + this._onConnectStateCb = cb + } + + /** 注册实时测量回调(测量过程中多次触发) */ + onMeasuring(cb: (data: ProgressData) => void): void { + this._onProgressCb = cb + } + + /** 注册测量完成回调(数据锁定,触发一次) */ + onLocked(cb: (data: LockData) => void): void { + this._onLockedCb = cb + } + + /** 注册设备断开回调 */ + onDisconnected(cb: () => void): void { + this._onDisconnectedCb = cb + } + + // ─── 扫描与连接 ─────────────────────────────── + + /** + * 开始蓝牙扫描 + * 先注册所有 bus 事件再调用 plugin.Blue.start() + * 页面 onShow 时调用 + */ + startScan(): void { + const plugin = this._p + + // 扫描到设备列表 + plugin.bus.subscribe('devicesList', (devList: RawDevice[]) => { + // 广播秤(BleAdv)自动调用 setbroadcastDev,无需用户手动点击连接 + const advDev = devList.find((item: RawDevice) => { + const model = plugin.Blue.getDeviceModel(item) + return model?.deviceConnectType === plugin.PPBluetoothDefine.PPDeviceConnectType.PPDeviceConnectTypeBleAdv + }) + if (advDev) { + plugin.Blue.setbroadcastDev(advDev) + } + + // 转换为展示层数据 + const devices: ScannedDevice[] = devList.map((item: RawDevice) => ({ + name: item.name || item.deviceName || '未知设备', + raw: item, + model: plugin.Blue.getDeviceModel(item) || {}, + })) + this._onDevicesListCb?.(devices) + }) + + // 协议对象(持有 activeProtocol,WiFi 配网等高级操作依赖它) + plugin.bus.subscribe('devicesModel', (res: any) => { + this._activeProtocol = res + }) + + // 连接成功 + plugin.bus.subscribe('deviceConnect', () => { + this._reconnectCount = 0 + plugin.ScaleAction.startDataProgress() + this._startKeepAlive() + }) + + // 连接状态变化 + plugin.bus.subscribe('connectState', (res: string) => { + this._onConnectStateCb?.(res) + if (res === plugin.BLUE_STATE.CONNECTFAILED) { + this._stopKeepAlive() + this._doReconnect() + } + }) + + // 实时测量数据 + plugin.bus.subscribe('progressData', (res: ProgressData) => { + this._onProgressCb?.(res) + }) + + // 测量锁定(完成) + plugin.bus.subscribe('lockData', (res: LockData) => { + this._onLockedCb?.(res) + }) + + // 设备自动断开 + plugin.bus.subscribe('deviceWillDisconnect', () => { + this._stopKeepAlive() + this._onDisconnectedCb?.() + this._doReconnect() + }) + + // 开始扫描,传入设备配置过滤列表 + plugin.Blue.start(DEVICE_SETTINGS) + } + + /** + * 主动连接指定设备 + * BleAdv 类型已在 devicesList 中自动处理,无需调用此方法 + * BleConnect 类型需要用户点击后调用 + */ + connect(device: ScannedDevice): void { + const connectType = this._p.PPBluetoothDefine.PPDeviceConnectType + if (device.model?.deviceConnectType === connectType.PPDeviceConnectTypeBleConnect) { + this._p.Blue.createBLEConnection(device.raw) + } + } + + /** 主动断开当前连接 */ + disconnect(): void { + this._stopKeepAlive() + this._p.Blue.disconnect() + } + + /** + * 停止扫描,清理所有 bus 订阅与回调 + * 页面 onUnload / onHide 时调用 + */ + stopScan(): void { + this._stopKeepAlive() + this._stopReconnectTimer() + this._p.Blue.stop() + this._activeProtocol = null + this._onDevicesListCb = null + this._onConnectStateCb = null + this._onProgressCb = null + this._onLockedCb = null + this._onDisconnectedCb = null + } + + // ─── WiFi 配网 ──────────────────────────────── + + /** + * 获取设备上报的 WiFi 列表(需设备已连接) + * 内部延迟 2 秒后调用,15 秒超时 + */ + getWifiList(): Promise { + return new Promise((resolve, reject) => { + if (!this._activeProtocol) { + reject(new Error('[LeFu] 设备未连接,无法获取 WiFi 列表')) + return + } + + const timer = setTimeout(() => { + reject(new Error('[LeFu] 获取 WiFi 列表超时')) + }, 15000) + + // 延迟 2 秒,等待设备就绪后再请求 + setTimeout(() => { + this._activeProtocol.dataFindSurroundDevice((res: any[]) => { + clearTimeout(timer) + if (!res || res.length === 0) { + this._activeProtocol.dataExitWifiConfig(() => {}) + resolve([]) + return + } + const list: LeFuWifiItem[] = res.map((item: any) => ({ + ssid: item.ssid || item.name || '', + signal: item.signal, + })) + resolve(list) + }) + }, 2000) + }) + } + + /** + * 将 WiFi 配置写入设备 + * @param ssid WiFi 名称 + * @param password WiFi 密码 + * @param version 协议版本:domain1(无鉴权)| domain2(有鉴权) + * 返回码 23 = 配网成功 + */ + configWifi(ssid: string, password: string, version: WifiVersion): Promise { + return new Promise((resolve, reject) => { + if (!this._activeProtocol) { + reject(new Error('[LeFu] 设备未连接,无法配网')) + return + } + + const callback = (res: number) => { + if (res === 23) { + resolve() + } else { + reject(new Error(`[LeFu] 配网失败,错误码:${res}`)) + } + } + + if (version === 'domain1') { + this._activeProtocol.dataConfigNetWork( + { domain: LEFU_CONFIG.domain1, ssid, password }, + callback, + ) + } else { + this._activeProtocol.dataConfigUserNetWork( + { + domain: LEFU_CONFIG.domain2, + ssid, + password, + userName: 'apiUser', + userPassword: '3acebb95eb49577e9c2a2082589b9bd6', + }, + callback, + ) + } + }) + } + + /** + * 查询设备是否已完成配网 + * 返回 true = 已配网(0x01),false = 未配网 + */ + checkWifiConfig(): Promise { + return new Promise((resolve, reject) => { + if (!this._activeProtocol) { + reject(new Error('[LeFu] 设备未连接')) + return + } + this._activeProtocol.codeFetchWifiConfig((res: number | undefined | null) => { + resolve(res === 0x01) + }) + }) + } + + // ─── 私有:保活与重连 ───────────────────────── + + private _startKeepAlive(): void { + this._stopKeepAlive() + this._keepAliveTimer = setInterval(() => { + this._activeProtocol?.sendKeepAliveCode?.() + }, 15000) + } + + private _stopKeepAlive(): void { + if (this._keepAliveTimer !== null) { + clearInterval(this._keepAliveTimer) + this._keepAliveTimer = null + } + } + + private _stopReconnectTimer(): void { + if (this._reconnectTimer !== null) { + clearTimeout(this._reconnectTimer) + this._reconnectTimer = null + } + this._reconnectCount = 0 + } + + /** 自动重连,最多 _maxReconnect 次,超限后等待用户手动重试 */ + private _doReconnect(): void { + if (this._reconnectCount >= this._maxReconnect) return + this._reconnectCount++ + this._stopReconnectTimer() + this._reconnectTimer = setTimeout(() => { + this._plugin?.Blue?.disconnect((res: any) => { + if (res?.errCode === 0) { + this._plugin?.Blue?.startBluetoothDevicesDiscovery() + } + }) + }, 2000) + } + + // ─── 成员同步 ───────────────────────────────── + + /** + * 全量将成员列表同步到设备 + * 流程:清除设备成员数据 → 逐个下发(顺序执行) + * 每步 15 秒超时保护,失败时自动重试当前成员 + * @param members 成员列表,必须包含且仅包含一个 isSelf=true 的主用户 + */ + syncMembersToDevice(members: DeviceMember[]): Promise { + return new Promise((resolve, reject) => { + if (!this._activeProtocol) { + reject(new Error('[LeFu] 设备未连接,无法同步成员')) + return + } + + const mainUser = members.find(m => m.isSelf) + if (!mainUser) { + reject(new Error('[LeFu] 成员列表中缺少主用户(isSelf=true)')) + return + } + + // 第一步:清除设备上的所有成员数据("01" 仅清成员,保留历史) + const clearTimer = setTimeout(() => { + reject(new Error('[LeFu] 清除设备成员超时')) + }, 15000) + + this._activeProtocol.codeClearDeviceData('01', (clearRes: number) => { + clearTimeout(clearTimer) + if (clearRes !== 0x00) { + reject(new Error('[LeFu] 清除设备成员失败')) + return + } + + // 第二步:逐个下发成员(顺序执行,失败自动重试当前) + let index = 0 + let syncTimer: number | null = null + + const resetTimer = () => { + if (syncTimer !== null) clearTimeout(syncTimer) + syncTimer = setTimeout(() => { + reject(new Error(`[LeFu] 第 ${index + 1} 个成员下发超时`)) + }, 15000) + } + + const syncNext = () => { + if (index >= members.length) { + if (syncTimer !== null) clearTimeout(syncTimer) + resolve() + return + } + + const member = members[index] + resetTimer() + + this._activeProtocol.dataSyncUserInfo( + { + userID: mainUser.id, + userName: member.name, + // 主用户 memberID 为空,子用户用自己的 id + memberID: member.isSelf ? '' : member.id, + age: member.age, + gender: member.gender, + height: member.height, + isAthleteMode: 0, + deviceHeaderIndex: index, + currentWeight: '', + targetWeight: '', + idealWeight: '', + recentData: [], + }, + (res: number) => { + if (res === 0) { + index++ + } + // 成功进下一个,失败重试当前(index 不变) + syncNext() + }, + ) + } + + syncNext() + }) + }) + } +} + +/** 全局单例,整个小程序共享同一个蓝牙服务实例 */ +export const lefuService = new LeFuService() diff --git a/miniprogram/lefu/types.ts b/miniprogram/lefu/types.ts new file mode 100644 index 0000000..80270d3 --- /dev/null +++ b/miniprogram/lefu/types.ts @@ -0,0 +1,97 @@ +/** ppScale-plugin 原始设备对象 */ +export type RawDevice = Record + +/** 乐福 SDK 插件对象(运行时由 requirePlugin 注入) */ +export type LeFuPlugin = Record + +/** WiFi 配网协议版本 */ +export type WifiVersion = 'domain1' | 'domain2' + +/** 设备配置(来自 master device.setting 列表) */ +export interface DeviceSetting { + advLength: number + calorieStatus: number + deviceAccuracyType: number + deviceCalcuteType: number + deviceConnectType: number + deviceFuncType: number + deviceName: string + devicePowerType: number + deviceProtocolType: number + deviceType: number + deviceUnitType: string + id: number + macAddressStart: number + sign: string + status: number + uhStatus: number + createBy: null + imgUrl: null + remark: null + updateBy: null +} + +/** 扫描到的设备(展示层使用) */ +export interface ScannedDevice { + /** 设备名称 */ + name: string + /** plugin 返回的原始设备对象,连接时传回给 SDK */ + raw: RawDevice + /** 设备模型(含 deviceConnectType 等信息) */ + model: Record +} + +/** WiFi 项 */ +export interface LeFuWifiItem { + /** WiFi SSID */ + ssid: string + /** 信号强度(部分设备上报) */ + signal?: number +} + +/** 测量进度数据(progressData 事件,测量过程中多次回调) */ +export interface ProgressData { + /** 体重,单位:克 */ + weight: number + /** 单位枚举值 */ + unit: number + /** 是否正在心率测量 */ + isHeartRating: boolean +} + +/** 测量锁定数据(lockData 事件,测量完成时回调一次) */ +export interface LockData { + /** 体重,单位:克 */ + weight: number + /** 阻抗值(用于体成分分析) */ + resistance: number + /** 心率(部分设备上报) */ + heartRate?: number + [key: string]: any +} + +/** 乐福服务配置 */ +export interface LeFuConfig { + key: string + secret: string + /** 老协议域名(无鉴权) */ + domain1: string + /** 新协议域名(有鉴权) */ + domain2: string +} + +/** 下发给设备的成员信息(lefu 层使用,业务层转换后传入) */ +export interface DeviceMember { + /** 成员唯一 ID */ + id: string + /** 姓名 */ + name: string + /** 性别:1=男,0=女 */ + gender: 0 | 1 + /** 年龄 */ + age: number + /** 身高(cm) */ + height: number + /** 是否本人(主用户) */ + isSelf: boolean +} diff --git a/miniprogram/mock/data.mock.ts b/miniprogram/mock/data.mock.ts new file mode 100644 index 0000000..1c1aa0a --- /dev/null +++ b/miniprogram/mock/data.mock.ts @@ -0,0 +1,69 @@ +/** 时间范围 */ +export type TimeRange = '7d' | '30d' | '3m' + +/** 单条测量记录 */ +export interface RecordItem { + id: string + /** 格式 YYYY.MM.DD HH:mm */ + time: string + weight: number + bmi: number + /** 体脂率(%) */ + bodyFat: number +} + +/** 统计指标 */ +export interface StatsData { + maxWeight: number + minWeight: number + avgWeight: number + /** 体重变化(kg,正=增重,负=减轻) */ + weightDiff: number +} + +/** 全量 mock 记录(最近 3 个月数据,按时间倒序) */ +const ALL_RECORDS: RecordItem[] = [ + { id: 'r-001', time: '2026.05.12 08:12', weight: 68.2, bmi: 22.0, bodyFat: 18.5 }, + { id: 'r-002', time: '2026.05.11 21:30', weight: 68.5, bmi: 22.1, bodyFat: 18.7 }, + { id: 'r-003', time: '2026.05.10 08:05', weight: 68.8, bmi: 22.3, bodyFat: 18.9 }, + { id: 'r-004', time: '2026.05.09 07:58', weight: 69.0, bmi: 22.4, bodyFat: 19.0 }, + { id: 'r-005', time: '2026.05.08 22:10', weight: 69.3, bmi: 22.5, bodyFat: 19.2 }, + { id: 'r-006', time: '2026.05.07 08:20', weight: 69.5, bmi: 22.6, bodyFat: 19.3 }, + { id: 'r-007', time: '2026.05.06 08:00', weight: 70.0, bmi: 22.7, bodyFat: 19.5 }, + { id: 'r-008', time: '2026.04.30 08:10', weight: 70.3, bmi: 22.8, bodyFat: 19.7 }, + { id: 'r-009', time: '2026.04.20 07:45', weight: 71.0, bmi: 23.0, bodyFat: 20.0 }, + { id: 'r-010', time: '2026.04.10 08:30', weight: 71.8, bmi: 23.2, bodyFat: 20.3 }, + { id: 'r-011', time: '2026.03.28 08:15', weight: 72.5, bmi: 23.5, bodyFat: 20.7 }, + { id: 'r-012', time: '2026.03.15 09:00', weight: 73.2, bmi: 23.7, bodyFat: 21.0 }, + { id: 'r-013', time: '2026.03.01 08:05', weight: 73.8, bmi: 23.9, bodyFat: 21.3 }, +] + +/** 各时间范围截取数量 */ +const RANGE_COUNTS: Record = { + '7d': 7, + '30d': 10, + '3m': 13, +} + +/** + * 按时间范围获取 mock 记录列表 + */ +export function getMockRecords(range: TimeRange): RecordItem[] { + return ALL_RECORDS.slice(0, RANGE_COUNTS[range]) +} + +/** + * 根据记录列表计算统计指标 + */ +export function calcStats(records: RecordItem[]): StatsData { + if (!records.length) { + return { maxWeight: 0, minWeight: 0, avgWeight: 0, weightDiff: 0 } + } + const weights = records.map(r => r.weight) + const maxWeight = Math.max(...weights) + const minWeight = Math.min(...weights) + const avgWeight = parseFloat((weights.reduce((a, b) => a + b, 0) / weights.length).toFixed(1)) + // 最新 - 最旧 + const weightDiff = parseFloat((weights[0] - weights[weights.length - 1]).toFixed(1)) + return { maxWeight, minWeight, avgWeight, weightDiff } +} diff --git a/miniprogram/mock/home.mock.ts b/miniprogram/mock/home.mock.ts new file mode 100644 index 0000000..e3279fa --- /dev/null +++ b/miniprogram/mock/home.mock.ts @@ -0,0 +1,40 @@ +/** 最近一次测量记录 */ +export interface WeightRecord { + /** 体重整数部分 */ + weight: number + /** 体重小数部分,含小数点,如 ".20" */ + weightDecimal: string + /** 测量时间显示,如 "今日 07:30" */ + date: string + /** 与上次相比差值(负=减轻,正=增重) */ + diff: number + /** 身高(cm) */ + height: number + /** BMI 值 */ + bmi: number + /** BMI 评级标签 */ + bmiLabel: string + /** 标准体重(kg) */ + stdWeight: number + /** 体脂率(%) */ + bodyFat: number + /** 体脂肪(kg) */ + bodyFatMass: number + /** 基础代谢(kcal) */ + bmr: number +} + +/** Mock 最近一次测量记录 */ +export const mockLatestRecord: WeightRecord = { + weight: 68, + weightDecimal: '.20', + date: '今日 07:30', + diff: -0.3, + height: 176, + bmi: 22.4, + bmiLabel: '标准', + stdWeight: 67.2, + bodyFat: 22.3, + bodyFatMass: 15.2, + bmr: 1652, +} diff --git a/miniprogram/mock/member.mock.ts b/miniprogram/mock/member.mock.ts new file mode 100644 index 0000000..7ce977c --- /dev/null +++ b/miniprogram/mock/member.mock.ts @@ -0,0 +1,69 @@ +/** 成员项(与 equipmentMember / data 页面共享结构) */ +export interface MemberItem { + id: string + name: string + gender: '男' | '女' + age: number + /** 身高(cm) */ + height: number + /** 头像色块 */ + avatar: string + isSelf: boolean + /** 他人添加的成员(只读,不显示编辑/删除) */ + isFromOther: boolean +} + +/** 头像色块池 */ +const AVATAR_COLORS = ['#1385FA', '#2AC79F', '#FF8FAB', '#FFB347', '#A78BFA', '#F87171'] + +/** Mock 成员列表 */ +export const mockMembers: MemberItem[] = [ + { + id: 'm-001', + name: '陈小飞', + gender: '男', + age: 28, + height: 175, + avatar: AVATAR_COLORS[0], + isSelf: true, + isFromOther: false, + }, + { + id: 'm-002', + name: '李镇南', + gender: '男', + age: 32, + height: 178, + avatar: AVATAR_COLORS[1], + isSelf: false, + isFromOther: false, + }, + { + id: 'm-003', + name: '张颖', + gender: '女', + age: 26, + height: 165, + avatar: AVATAR_COLORS[2], + isSelf: false, + isFromOther: false, + }, + { + id: 'm-004', + name: '王建国', + gender: '男', + age: 60, + height: 170, + avatar: AVATAR_COLORS[3], + isSelf: false, + isFromOther: true, + }, +] + +/** + * 生成下一个可用的头像色块 + * @param existingCount 已有成员数量 + */ +export function nextAvatarColor(existingCount: number): string { + return AVATAR_COLORS[existingCount % AVATAR_COLORS.length] +} diff --git a/miniprogram/pages/connectedDevice/connectedDevice.ts b/miniprogram/pages/connectedDevice/connectedDevice.ts index 63b083e..20d6e03 100644 --- a/miniprogram/pages/connectedDevice/connectedDevice.ts +++ b/miniprogram/pages/connectedDevice/connectedDevice.ts @@ -1,102 +1,102 @@ -/** 设备项模型 */ +import { lefuService } from '../../lefu/index' +import type { ScannedDevice } from '../../lefu/index' + +/** 展示用设备项 */ interface DeviceItem { - /** 设备唯一 id */ - id: string /** 设备名称 */ name: string - /** 设备图标 */ - icon: string /** 是否已连接 */ connected: boolean + /** 原始设备对象(连接时传回给 SDK) */ + _raw: ScannedDevice } /** 页面状态 */ type DeviceStatus = 'idle' | 'searching' | 'found' | 'empty' -// 是否 mock 能搜索到设备,false 则走空状态 -const MOCK_HAS_DEVICES = true - -// 模拟搜索耗时(毫秒) -const MOCK_SEARCH_DURATION = 2000 - Page({ - /* 搜索定时器引用,onUnload 时清理 */ - _searchTimer: null as number | null, - data: { /** * 页面状态 - * - idle: 未搜索(进入页面默认态) - * - searching:搜索中 - * - found: 搜索完成且有设备 - * - empty: 搜索完成但无设备 + * - idle: 未搜索(进入页面默认态) + * - searching: 扫描中 + * - found: 扫描完成且有设备 + * - empty: 扫描完成但无设备 */ status: 'idle' as DeviceStatus, - /* 设备列表(包含已连接和未连接,每项含 connected: boolean) */ + /** 扫描到的设备列表 */ deviceList: [] as DeviceItem[], }, - onLoad() { + onShow() { + // 注册回调后开始扫描 + lefuService.onDevicesList((devices) => { + if (!devices.length) { + this.setData({ status: 'empty', deviceList: [] }) + return + } + const list: DeviceItem[] = devices.map(d => ({ + name: d.name, + connected: false, + _raw: d, + })) + this.setData({ status: 'found', deviceList: list }) + }) + lefuService.onConnectState((state) => { + const plugin = lefuService['_plugin'] + if (!plugin) return + // 连接成功后跳转配网页 + if (state === plugin.BLUE_STATE.CONNECTSUCCESS || state === plugin.BLUE_STATE.WIFISUCCESS) { + wx.navigateTo({ url: '/pages/connectedWifi/connectedWifi' }) + } + }) + + this.onStartSearch() + }, + + onHide() { + // 页面隐藏时停止扫描,释放 bus 订阅 + lefuService.stopScan() }, onUnload() { - /* 清除搜索定时器,防止页面卸载后还触发 setData */ - if (this._searchTimer) { - clearTimeout(this._searchTimer) - this._searchTimer = null - } + lefuService.stopScan() }, - /* 开始搜索 / 重新搜索 */ + /** 开始 / 重新搜索 */ onStartSearch() { - this.setData({ - status: 'searching', - deviceList: [], - }) - - /* 模拟搜索,后续接入蓝牙真实 API 时删除 */ - this._searchTimer = setTimeout(() => { - if (MOCK_HAS_DEVICES) { - this.setData({ - status: 'found', - deviceList: [ - { id: 'mock-connected', name: '智能体重秤 Pro', icon: '', connected: true }, - { id: 'mock-1', name: '体重秤 Basic', icon: '', connected: false }, - { id: 'mock-2', name: '体脂秤 Plus', icon: '', connected: false }, - ], - }) - } else { - this.setData({ status: 'empty' }) - } - }, MOCK_SEARCH_DURATION) + this.setData({ status: 'searching', deviceList: [] }) + lefuService.startScan() }, - /* 连接某台设备:同时只能一台已连接,自动断开其他设备 */ + /** 连接指定设备 */ onConnect(e: WechatMiniprogram.TouchEvent) { - const { id } = e.currentTarget.dataset - const deviceList = this.data.deviceList.map(item => ({ + const index = e.currentTarget.dataset.index as number + const target = this.data.deviceList[index] + if (!target) return + + // 更新 UI 连接状态 + const list = this.data.deviceList.map((item, i) => ({ ...item, - connected: item.id === id, + connected: i === index, })) - this.setData({ deviceList }) - /* TODO: 接入真实蓝牙连接逻辑 */ + this.setData({ deviceList: list }) + + // 调用 SDK 连接(BleAdv 类型已在 startScan 内自动处理,BleConnect 类型需此处触发) + lefuService.connect(target._raw) }, - /* 断开当前已连接设备 */ + /** 断开当前连接 */ onDisconnect() { - const deviceList = this.data.deviceList.map(item => ({ - ...item, - connected: false, - })) - this.setData({ deviceList }) - /* TODO: 接入真实蓝牙断开逻辑 */ + const list = this.data.deviceList.map(item => ({ ...item, connected: false })) + this.setData({ deviceList: list }) + lefuService.disconnect() }, - /* 查看帮助 */ + /** 查看帮助 */ onOpenHelp() { - console.log('查看帮助') - /* TODO: 跳帮助页 */ + wx.showToast({ title: '请确保设备已开机且在附近', icon: 'none' }) }, }) diff --git a/miniprogram/pages/connectedWifi/connectedWifi.ts b/miniprogram/pages/connectedWifi/connectedWifi.ts index 63e1489..a5cdd89 100644 --- a/miniprogram/pages/connectedWifi/connectedWifi.ts +++ b/miniprogram/pages/connectedWifi/connectedWifi.ts @@ -1,179 +1,167 @@ -/** WiFi 项模型 */ +import { lefuService } from '../../lefu/index' +import type { LeFuWifiItem, WifiVersion } from '../../lefu/index' + +/** WiFi 展示项 */ interface WifiItem { - /** WiFi 唯一 id */ - id: string - /** WiFi 名称(SSID) */ - name: string + ssid: string /** 是否已通过本页配网成功 */ connected: boolean } -/** 当前已连接设备(顶部卡片) */ +/** 当前已连接设备(顶部卡片) */ interface CurrentDevice { - /** 设备唯一 id */ id: string - /** 设备名称 */ name: string } /** 密码态选中的 WiFi */ interface SelectedWifi { - /** WiFi id,空字符串表示未选中 */ - id: string - /** WiFi 名称 */ - name: string + ssid: string } /** 页面状态 */ -type WifiStatus = 'idle' | 'searching' | 'list' | 'empty' | 'password' - -// 是否 mock 能搜索到 WiFi,false 则走空状态 -const MOCK_HAS_WIFI = true - -// 模拟搜索耗时(毫秒) -const MOCK_SEARCH_DURATION = 600 +type WifiStatus = 'idle' | 'searching' | 'list' | 'empty' | 'password' | 'configuring' Page({ - /* 搜索定时器引用,onUnload 时清理 */ - _searchTimer: null as number | null, + /** 配网协议版本,两种都支持,默认 domain2(有鉴权) */ + _wifiVersion: 'domain2' as WifiVersion, data: { /** * 页面状态 - * - idle: 未搜索(进入页面默认态,onLoad 立即流转) - * - searching: 搜索中 - * - list: 搜索完成且有 WiFi - * - empty: 搜索完成但无 WiFi - * - password: 从列表选中某项后进入密码输入态 + * - idle: 未搜索 + * - searching: 正在从设备获取 WiFi 列表 + * - list: 列表已就绪 + * - empty: 设备未扫描到任何 WiFi + * - password: 选中某项,正在输入密码 + * - configuring: 正在将 WiFi 写入设备 */ status: 'idle' as WifiStatus, - /* 当前已连接的设备(从上一页带过来的固定卡片信息) */ + /** 顶部固定卡片(从上一页状态中读取,当前先用占位) */ currentDevice: { - id: 'mock-device', - name: '智能体重秤 Pro', + id: '', + name: '智能体重秤', } as CurrentDevice, - /* WiFi 列表(connected: true 表示已通过本页配网成功的网络) */ + /** WiFi 列表 */ wifiList: [] as WifiItem[], - /* 是否已有任一 WiFi 配网成功,决定底部「确认」按钮是否可点击 */ + /** 是否已有任一 WiFi 配网成功 */ hasConnected: false, - /* 当前在密码态选中的 WiFi */ - selectedWifi: { - id: '', - name: '', - } as SelectedWifi, + /** 当前密码态选中的 WiFi */ + selectedWifi: { ssid: '' } as SelectedWifi, - /* 密码输入框值 */ + /** 密码输入框值 */ selectWifiPWD: '', - /* 密码框明文/密文切换:true 为密文(默认),false 为明文 */ + /** 密码框明文/密文切换:true 为密文(默认) */ type: true, }, onLoad() { - /* 设计稿表达"进入页面立即搜索",但状态机仍由 idle 起步, - * 此处手动调用按钮事件,避免"自动逻辑"与"按钮逻辑"分叉 */ + // idle 起步,通过统一入口触发搜索 this.onStartSearch() }, onUnload() { - /* 清除搜索定时器,防止页面卸载后还触发 setData */ - if (this._searchTimer) { - clearTimeout(this._searchTimer) - this._searchTimer = null - } + // 页面卸载时不调用 stopScan,蓝牙连接由 home 页维持 }, - /* 开始搜索:idle/empty/list → searching → list/empty */ + /** 从设备获取 WiFi 列表 */ onStartSearch() { - this.setData({ status: 'searching' }) + this.setData({ status: 'searching', wifiList: [] }) - this._searchTimer = setTimeout(() => { - if (MOCK_HAS_WIFI) { - this.setData({ - status: 'list', - wifiList: [ - { id: 'wifi-1', name: 'Rk-Health-5G', connected: false }, - { id: 'wifi-2', name: 'Rk-Guest', connected: false }, - { id: 'wifi-3', name: 'ChinaNet-7Hk2', connected: false }, - { id: 'wifi-4', name: 'TP-LINK_8899', connected: false }, - ], - }) - } else { - this.setData({ status: 'empty', wifiList: [] }) - } - }, MOCK_SEARCH_DURATION) + lefuService.getWifiList() + .then((items: LeFuWifiItem[]) => { + if (!items.length) { + this.setData({ status: 'empty' }) + return + } + const list: WifiItem[] = items.map(item => ({ + ssid: item.ssid, + connected: false, + })) + this.setData({ status: 'list', wifiList: list }) + }) + .catch(() => { + this.setData({ status: 'empty' }) + }) }, - /* 空态「刷新」:重新进入搜索 */ + /** 空态刷新 */ onRefresh() { this.onStartSearch() }, - /* 点击某个 WiFi 项 → 进密码态 */ + /** 点击 WiFi 项 → 进密码态 */ onTapWifi(e: WechatMiniprogram.TouchEvent) { - const id = e.currentTarget.dataset.id as string - const target = this.data.wifiList.find(item => item.id === id) - if (!target) return - - /* 已连接项再次点击不进入密码态 */ - if (target.connected) return + const ssid = e.currentTarget.dataset.ssid as string + const target = this.data.wifiList.find(item => item.ssid === ssid) + if (!target || target.connected) return this.setData({ status: 'password', - selectedWifi: { id: target.id, name: target.name }, + selectedWifi: { ssid }, selectWifiPWD: '', type: true, }) }, - /* 密码输入 */ + /** 密码输入 */ passwordInput(e: WechatMiniprogram.Input) { this.setData({ selectWifiPWD: e.detail.value }) }, - /* 密码明文/密文切换 */ + /** 明文/密文切换 */ inputTypeChange() { this.setData({ type: !this.data.type }) }, - /* 密码态「取消」:回列表态,清空当前选中 */ + /** 密码态「取消」→ 回列表 */ onCancelPwd() { this.setData({ status: 'list', - selectedWifi: { id: '', name: '' }, + selectedWifi: { ssid: '' }, selectWifiPWD: '', }) }, - /* 密码态「连接」:把对应项标 connected,回列表态 */ + /** 密码态「连接」→ 将 WiFi 写入设备 */ onConfirmPwd() { - const { selectedWifi, wifiList } = this.data - if (!selectedWifi.id) return + const { selectedWifi, selectWifiPWD } = this.data + if (!selectedWifi.ssid) return - const next = wifiList.map(item => ({ - ...item, - connected: item.id === selectedWifi.id, - })) + this.setData({ status: 'configuring' }) + wx.showLoading({ title: '正在配网...', mask: true }) - this.setData({ - status: 'list', - wifiList: next, - hasConnected: true, - selectedWifi: { id: '', name: '' }, - selectWifiPWD: '', - }) + lefuService.configWifi(selectedWifi.ssid, selectWifiPWD, this._wifiVersion) + .then(() => { + wx.hideLoading() + // 配网成功:标记该 WiFi,回列表态 + const next = this.data.wifiList.map(item => ({ + ...item, + connected: item.ssid === selectedWifi.ssid, + })) + this.setData({ + status: 'list', + wifiList: next, + hasConnected: true, + selectedWifi: { ssid: '' }, + selectWifiPWD: '', + }) + }) + .catch((err: Error) => { + wx.hideLoading() + wx.showToast({ title: err.message || '配网失败,请重试', icon: 'none' }) + this.setData({ status: 'password' }) + }) }, - /* 列表底部「确认」:必须有连接成功的 WiFi 才可点击 */ + /** 底部「确认」:已有 WiFi 配网成功才可点击 */ onConfirm() { if (!this.data.hasConnected) return - - wx.navigateTo({ - url: '/pages/supplementPersonal/supplementPersonal', - }) + wx.navigateTo({ url: '/pages/supplementPersonal/supplementPersonal' }) }, }) diff --git a/miniprogram/pages/data/data.less b/miniprogram/pages/data/data.less index 3e9c716..d4cee05 100644 --- a/miniprogram/pages/data/data.less +++ b/miniprogram/pages/data/data.less @@ -40,6 +40,7 @@ height: 90rpx; margin-right: 24rpx; border-radius: 18rpx; + background: var(--avatar-bg); } /* 姓名 + meta */ diff --git a/miniprogram/pages/data/data.ts b/miniprogram/pages/data/data.ts index b39e0fe..58f9a53 100644 --- a/miniprogram/pages/data/data.ts +++ b/miniprogram/pages/data/data.ts @@ -1,159 +1,73 @@ -/** 时间范围 */ -type TimeRange = '7d' | '30d' | '3m' +import { getMockRecords, calcStats } from '../../mock/data.mock' +import type { TimeRange, RecordItem, StatsData } from '../../mock/data.mock' +import type { MemberItem } from '../../mock/member.mock' +import { mockMembers } from '../../mock/member.mock' /** 加载状态机 */ type LoadStatus = 'idle' | 'loading' | 'loaded' -/** 成员信息 */ -interface MemberInfo { - /** 唯一 ID */ - id: string - /** 姓名 */ - name: string - /** 性别 */ - gender: '男' | '女' - /** 年龄 */ - age: number - /** 身高(cm) */ - height: number - /** 头像色块(无图片时使用) */ - avatar: string - /** 是否本人 */ - isSelf: boolean -} - -/** 统计指标 */ -interface StatsData { - /** 最高体重(kg) */ - maxWeight: number - /** 最低体重(kg) */ - minWeight: number - /** 平均体重(kg) */ - avgWeight: number - /** 体重变化(kg,正负) */ - weightDiff: number -} - /** 时间范围 Tab 项 */ interface RangeTab { - key: TimeRange - label: string + key: TimeRange + label: string } -/** 单条测量记录 */ -interface RecordItem { - /** 唯一 ID */ - id: string - /** 测量时间,格式 YYYY.MM.DD HH:mm */ - time: string - /** 体重(kg) */ - weight: number - /** BMI */ - bmi: number - /** 体脂率(%) */ - bodyFat: number -} - -/** - * 历史数据页 - * 状态机:idle → loading → loaded - * 切换时间范围 / 切换成员 均复用同一加载流程 - */ Page({ - data: { - // 加载状态 - status: 'idle' as LoadStatus, + data: { + status: 'idle' as LoadStatus, + activeRange: '7d' as TimeRange, + rangeTabs: [ + { key: '7d', label: '近7天' }, + { key: '30d', label: '近30天' }, + { key: '3m', label: '近3月' }, + ] as RangeTab[], - // 当前选中的时间范围 - activeRange: '7d' as TimeRange, + /** 当前查看的成员(默认本人) */ + currentMember: null as MemberItem | null, - // 时间范围 Tab 选项 - rangeTabs: [ - { key: '7d', label: '近7天' }, - { key: '30d', label: '近30天' }, - { key: '3m', label: '近3月' } - ] as RangeTab[], + stats: null as StatsData | null, + recordList: [] as RecordItem[], + }, - // 当前成员 - currentMember: { - id: 'm-001', - name: '陈小飞', - gender: '男', - age: 28, - height: 175, - avatar: '#1385FA', - isSelf: true - } as MemberInfo, + onLoad() { + // 默认选本人(isSelf=true),后备取第一个 + const members: MemberItem[] = wx.getStorageSync('memberList') || mockMembers + const self = members.find(m => m.isSelf) || members[0] || null + this.setData({ currentMember: self }) + this.loadData() + }, - // 统计数据 - stats: { - maxWeight: 73.5, - minWeight: 71.2, - avgWeight: 72.3, - weightDiff: -2.3 - } as StatsData, + onReady() {}, + onShow() {}, + onHide() {}, + onUnload() {}, + onPullDownRefresh() {}, + onReachBottom() {}, - // 历史记录列表 - recordList: [] as RecordItem[] - }, + onShareAppMessage(): WechatMiniprogram.Page.ICustomShareContent { + return {} + }, - onLoad() { - // idle 起步,通过统一入口触发加载 - this.loadData() - }, + /** 加载数据(切 Tab / 切成员 复用同一入口) */ + loadData() { + this.setData({ status: 'loading' }) + setTimeout(() => { + const records = getMockRecords(this.data.activeRange) + const stats = calcStats(records) + this.setData({ recordList: records, stats, status: 'loaded' }) + }, 300) + }, - onReady() {}, + /** 切换时间范围 Tab */ + onTapRange(e: WechatMiniprogram.TouchEvent) { + const key = e.currentTarget.dataset.key as TimeRange + if (key === this.data.activeRange) return + this.setData({ activeRange: key }) + this.loadData() + }, - onShow() {}, - - onHide() {}, - - onUnload() {}, - - onPullDownRefresh() {}, - - onReachBottom() {}, - - onShareAppMessage(opts): WechatMiniprogram.Page.ICustomShareContent { - console.log(opts.target) - return {} - }, - - /** - * 加载数据(切 Tab / 切成员 复用) - */ - loadData() { - this.setData({ status: 'loading' }) - - // TODO: 后续接入真实接口,这里先用 mock - const mockList: RecordItem[] = [ - { id: 'r-001', time: '2026.05.12 08:12', weight: 71.2, bmi: 23.2, bodyFat: 18.5 }, - { id: 'r-002', time: '2026.05.11 21:30', weight: 71.8, bmi: 23.4, bodyFat: 18.7 }, - { id: 'r-003', time: '2026.05.11 08:05', weight: 71.5, bmi: 23.3, bodyFat: 18.6 }, - { id: 'r-004', time: '2026.05.10 07:58', weight: 72.0, bmi: 23.5, bodyFat: 18.9 }, - { id: 'r-005', time: '2026.05.10 22:10', weight: 72.6, bmi: 23.7, bodyFat: 19.1 }, - { id: 'r-006', time: '2026.05.09 08:20', weight: 72.8, bmi: 23.8, bodyFat: 19.2 }, - { id: 'r-007', time: '2026.05.08 08:00', weight: 73.5, bmi: 24.0, bodyFat: 19.5 } - ] - - this.setData({ recordList: mockList, status: 'loaded' }) - }, - - /** - * 切换时间范围 Tab - */ - onTapRange(e: WechatMiniprogram.TouchEvent) { - const key = e.currentTarget.dataset.key as TimeRange - if (key === this.data.activeRange) return - this.setData({ activeRange: key }) - this.loadData() - }, - - /** - * 切换成员(后续接入成员选择浮层) - */ - onTapSwitchMember() { - // TODO: 接入成员选择浮层 - wx.showToast({ title: '切换成员', icon: 'none' }) - } + /** 切换成员 */ + onTapSwitchMember() { + wx.showToast({ title: '切换成员', icon: 'none' }) + }, }) diff --git a/miniprogram/pages/data/data.wxml b/miniprogram/pages/data/data.wxml index ab22ca6..85b5863 100644 --- a/miniprogram/pages/data/data.wxml +++ b/miniprogram/pages/data/data.wxml @@ -6,7 +6,7 @@ - + {{ currentMember.name }} diff --git a/miniprogram/pages/equipmentMember/equipmentMember.less b/miniprogram/pages/equipmentMember/equipmentMember.less index 3e89684..1250de4 100644 --- a/miniprogram/pages/equipmentMember/equipmentMember.less +++ b/miniprogram/pages/equipmentMember/equipmentMember.less @@ -162,6 +162,7 @@ margin-right: 24rpx; box-sizing: border-box; border: 3rpx solid #50A6FF; + background: var(--avatar-bg); } .member-info { diff --git a/miniprogram/pages/equipmentMember/equipmentMember.ts b/miniprogram/pages/equipmentMember/equipmentMember.ts index 8306ea1..4410412 100644 --- a/miniprogram/pages/equipmentMember/equipmentMember.ts +++ b/miniprogram/pages/equipmentMember/equipmentMember.ts @@ -1,135 +1,132 @@ -/** 成员项 */ -interface MemberItem { - /** 唯一 ID */ - id: string - /** 姓名 */ - name: string - /** 性别 */ - gender: '男' | '女' - /** 年龄 */ - age: number - /** 身高(cm) */ - height: number - /** 头像色块(无图片时使用) */ - avatar: string - /** 是否本人 */ - isSelf: boolean - /** 是否他人添加(只读,不显示编辑/删除) */ - isFromOther: boolean -} +import { lefuService } from '../../lefu/index' +import type { DeviceMember } from '../../lefu/index' +import { mockMembers } from '../../mock/member.mock' +import type { MemberItem } from '../../mock/member.mock' /** 设备卡片 */ interface DeviceCard { - /** 设备 ID */ - id: string - /** 设备名称 */ - name: string - /** 已使用容量(成员数) */ - capacityUsed: number - /** 总容量 */ - capacityTotal: number + id: string + name: string + capacityUsed: number + capacityTotal: number } /** - * 设备成员页 - * 两态:已连接(connected=true) / 未连接(connected=false) - * connected 为页面内部全局状态,后续接入真实设备状态时替换 + * MemberItem → DeviceMember 转换 + * lefu 层只认识 DeviceMember,业务层负责转换 */ +function toDeviceMember(m: MemberItem): DeviceMember { + return { + id: m.id, + name: m.name, + gender: m.gender === '男' ? 1 : 0, + age: m.age, + height: m.height, + isSelf: m.isSelf, + } +} + +/** + * 将成员列表全量同步到设备 + * 成功/失败均给出 Toast 提示 + */ +function syncToDevice(members: MemberItem[]): void { + const deviceMembers = members.map(toDeviceMember) + wx.showLoading({ title: '正在同步成员...', mask: true }) + lefuService.syncMembersToDevice(deviceMembers) + .then(() => { + wx.hideLoading() + wx.showToast({ title: '成员同步成功', icon: 'success' }) + }) + .catch((err: Error) => { + wx.hideLoading() + wx.showToast({ title: err.message || '同步失败,请重试', icon: 'none' }) + }) +} + Page({ - data: { - // 设备连接状态(全局,内部控制) - connected: true, + data: { + /** 设备连接状态(后续接入 lefuService.onConnectState 替换) */ + connected: true, - // 设备卡片 - deviceCard: { - id: 'device-001', - name: 'MX-W30A1', - capacityUsed: 4, - capacityTotal: 10 - } as DeviceCard, + deviceCard: { + id: 'device-001', + name: 'MX-W30A1', + capacityUsed: 0, + capacityTotal: 10, + } as DeviceCard, - // 成员列表(色块占位头像) - memberList: [ - { - id: 'm-001', - name: '陈小飞', - gender: '男', - age: 28, - height: 175, - avatar: '#1385FA', - isSelf: true, - isFromOther: false - }, - { - id: 'm-002', - name: '李镇南', - gender: '男', - age: 32, - height: 178, - avatar: '#2AC79F', - isSelf: false, - isFromOther: false - }, - { - id: 'm-003', - name: '张颖', - gender: '女', - age: 26, - height: 165, - avatar: '#FF8FAB', - isSelf: false, - isFromOther: false - }, - { - id: 'm-004', - name: '李镇南', - gender: '男', - age: 60, - height: 170, - avatar: '#FFB347', - isSelf: false, - isFromOther: true - } - ] as MemberItem[] - }, + memberList: [] as MemberItem[], + }, - /** - * 编辑成员 - */ - onTapEdit(e: WechatMiniprogram.TouchEvent) { - const id = e.currentTarget.dataset.id as string - // TODO: 后续接入编辑成员浮层/页面 - wx.showToast({ title: `编辑 ${id}`, icon: 'none' }) - }, + onLoad() { + this._loadMembers() + }, - /** - * 删除成员 - */ - onTapDelete(e: WechatMiniprogram.TouchEvent) { - const id = e.currentTarget.dataset.id as string - wx.showModal({ - title: '提示', - content: '确定删除该成员?', - success: (res) => { - if (!res.confirm) return - // TODO: 后续接入删除成员接口 - const list = this.data.memberList.filter(m => m.id !== id) - this.setData({ - memberList: list, - 'deviceCard.capacityUsed': list.length - }) - } - }) - }, + /** 从 storage / mock 加载成员列表 */ + _loadMembers() { + const list: MemberItem[] = wx.getStorageSync('memberList') || mockMembers + this.setData({ + memberList: list, + 'deviceCard.capacityUsed': list.length, + }) + }, - /** - * 添加成员:跳转到 addMember 页 - * 未连接态禁用 - */ - onTapAddMember() { - if (!this.data.connected) return - wx.navigateTo({ - url: '/pages/addMember/addMember' - }) - } + /** 编辑成员 */ + onTapEdit(e: WechatMiniprogram.TouchEvent) { + const id = e.currentTarget.dataset.id as string + // TODO: 跳转编辑成员页面,编辑完成后同样需要调用 syncToDevice + wx.showToast({ title: `编辑 ${id}`, icon: 'none' }) + }, + + /** 删除成员:更新本地 → 同步设备 */ + onTapDelete(e: WechatMiniprogram.TouchEvent) { + const id = e.currentTarget.dataset.id as string + wx.showModal({ + title: '提示', + content: '确定删除该成员?', + success: (res) => { + if (!res.confirm) return + + const list = this.data.memberList.filter(m => m.id !== id) + wx.setStorageSync('memberList', list) + this.setData({ + memberList: list, + 'deviceCard.capacityUsed': list.length, + }) + + // 删除后立即同步到设备 + if (this.data.connected) { + syncToDevice(list) + } + }, + }) + }, + + /** 添加成员:跳转 addMember 页(未连接态禁用) */ + onTapAddMember() { + if (!this.data.connected) return + wx.navigateTo({ + url: '/pages/addMember/addMember', + }) + }, + + /** + * addMember 页保存成功后返回时刷新列表并同步设备 + * 在 onShow 中检测 storage 变化 + */ + onShow() { + const stored: MemberItem[] = wx.getStorageSync('memberList') || [] + const current = this.data.memberList + // 数量有变化说明刚刚新增了成员 + if (stored.length !== current.length) { + this.setData({ + memberList: stored, + 'deviceCard.capacityUsed': stored.length, + }) + if (this.data.connected) { + syncToDevice(stored) + } + } + }, }) diff --git a/miniprogram/pages/equipmentMember/equipmentMember.wxml b/miniprogram/pages/equipmentMember/equipmentMember.wxml index 7db230f..385634e 100644 --- a/miniprogram/pages/equipmentMember/equipmentMember.wxml +++ b/miniprogram/pages/equipmentMember/equipmentMember.wxml @@ -38,7 +38,7 @@ - + diff --git a/miniprogram/pages/home/home.ts b/miniprogram/pages/home/home.ts index 464096c..451f7c8 100644 --- a/miniprogram/pages/home/home.ts +++ b/miniprogram/pages/home/home.ts @@ -1,118 +1,61 @@ +import { mockLatestRecord } from '../../mock/home.mock' +import type { WeightRecord } from '../../mock/home.mock' + /** 加载状态机 */ type LoadStatus = 'idle' | 'loading' | 'loaded' /** 设备信息 */ interface DeviceInfo { - /** 设备名称 */ - name: string - /** 蓝牙是否已连接 */ - connected: boolean + name: string + connected: boolean } -/** 最近一次测量记录 */ -interface WeightRecord { - /** 体重整数部分 */ - weight: number - /** 体重小数部分,含小数点,如 ".20" */ - weightDecimal: string - /** 测量时间显示,如 "今日 07:30" */ - date: string - /** 与上次相比差值(负=减轻,正=增重) */ - diff: number - /** 身高(cm) */ - height: number - /** BMI 值 */ - bmi: number - /** BMI 评级标签,如 "标准" */ - bmiLabel: string - /** 标准体重(kg) */ - stdWeight: number - /** 体脂率(%) */ - bodyFat: number - /** 体脂肪(kg) */ - bodyFatMass: number - /** 基础代谢(kcal) */ - bmr: number -} - -/** - * 首页 - * 状态机:idle → loading → loaded - * loaded 后按 hasData 分为有数据态 / 空态 - */ Page({ - data: { - // 加载状态 - status: 'idle' as LoadStatus, + data: { + status: 'idle' as LoadStatus, + hasData: false, + device: { + name: '智能体重秤', + connected: false, + } as DeviceInfo, + record: null as WeightRecord | null, + }, - // 是否有称重数据 - hasData: true, + onLoad() { + this.loadData() + }, - // 设备信息 - device: { - name: '智能体重秤 Pro', - connected: true - } as DeviceInfo, + onReady() {}, + onShow() {}, + onHide() {}, + onUnload() {}, + onPullDownRefresh() {}, + onReachBottom() {}, - // 最近一次测量记录(hasData=true 时有效) - record: { - weight: 68, - weightDecimal: '.20', - date: '今日 07:30', - diff: -0.3, - height: 176, - bmi: 22.4, - bmiLabel: '标准', - stdWeight: 67.2, - bodyFat: 22.3, - bodyFatMass: 52.1, - bmr: 1652 - } as WeightRecord - }, + onShareAppMessage(): WechatMiniprogram.Page.ICustomShareContent { + return {} + }, - onLoad() { - // idle 起步,通过统一入口触发加载 - this.loadData() - }, + /** 加载首页数据(当前使用 mock,后续替换为真实接口) */ + loadData() { + this.setData({ status: 'loading' }) + // 模拟异步加载 + setTimeout(() => { + this.setData({ + status: 'loaded', + hasData: true, + record: mockLatestRecord, + }) + }, 300) + }, - onReady() {}, + /** 跳转历史数据页 */ + onTapHistory() { + wx.switchTab({ url: '/pages/data/data' }) + }, - onShow() {}, - - onHide() {}, - - onUnload() {}, - - onPullDownRefresh() {}, - - onReachBottom() {}, - - onShareAppMessage(opts): WechatMiniprogram.Page.ICustomShareContent { - console.log(opts.target) - return {} - }, - - /** - * 加载首页数据(后续接入真实接口) - */ - loadData() { - this.setData({ status: 'loading' }) - // TODO: 后续接入真实接口,当前使用 mock - this.setData({ status: 'loaded' }) - }, - - /** - * 跳转数据页查看历史 - */ - onTapHistory() { - wx.switchTab({ url: '/pages/data/data' }) - }, - - /** - * 切换设备(后续接入设备选择浮层) - */ - onTapSwitchDevice() { - // TODO: 接入设备选择浮层 - wx.showToast({ title: '切换设备', icon: 'none' }) - } + /** 切换设备 */ + onTapSwitchDevice() { + wx.showToast({ title: '切换设备', icon: 'none' }) + }, }) diff --git a/miniprogram/utils/request/index.ts b/miniprogram/utils/request/index.ts new file mode 100644 index 0000000..e7a75ca --- /dev/null +++ b/miniprogram/utils/request/index.ts @@ -0,0 +1,87 @@ +import type { ApiResponse, HttpMethod, RequestOptions } from './types' + +export type { ApiResponse, RequestOptions } + +const BASE_URL = 'https://device.shuziweidao.com/gateway/' + +let _loadingCount = 0 + +const _showLoading = (title: string): void => { + if (_loadingCount === 0) wx.showLoading({ title, mask: true }) + _loadingCount++ +} + +const _hideLoading = (): void => { + if (_loadingCount > 0) _loadingCount-- + if (_loadingCount === 0) wx.hideLoading() +} + +const request = ( + url: string, + data?: Record, + method: HttpMethod = 'GET', + options: RequestOptions = {}, +): Promise> => { + const { loading = true, loadingTitle = '加载中...', contentType = 'application/json' } = options + + if (loading) _showLoading(loadingTitle) + + const header: Record = { 'content-type': contentType } + const token = wx.getStorageSync('token') as string + if (token) header['X-Access-Token'] = token + + return new Promise((resolve, reject) => { + wx.request({ + url: BASE_URL + url, + data, + method, + header, + success: (res) => { + if (loading) _hideLoading() + const body = res.data as ApiResponse + if (body.code === 200) { + resolve(body) + } else if (body.code === 401) { + wx.clearStorageSync() + wx.reLaunch({ url: '/pages/login/login' }) + reject(body) + } else { + wx.showToast({ title: body.message || '请求失败', icon: 'none' }) + reject(body) + } + }, + fail: (err) => { + if (loading) _hideLoading() + wx.showToast({ title: '网络异常,请重试', icon: 'none' }) + reject(err) + }, + }) + }) +} + +export const get = ( + url: string, + data?: Record, + options?: RequestOptions, +): Promise> => request(url, data, 'GET', options) + +export const post = ( + url: string, + data?: Record, + options?: RequestOptions, +): Promise> => request(url, data, 'POST', options) + +export const put = ( + url: string, + data?: Record, + options?: RequestOptions, +): Promise> => request(url, data, 'PUT', options) + +export const del = ( + url: string, + data?: Record, + options?: RequestOptions, +): Promise> => request(url, data, 'DELETE', { + contentType: 'application/x-www-form-urlencoded', + ...options, +}) diff --git a/miniprogram/utils/request/types.ts b/miniprogram/utils/request/types.ts new file mode 100644 index 0000000..6c8e588 --- /dev/null +++ b/miniprogram/utils/request/types.ts @@ -0,0 +1,16 @@ +export interface ApiResponse { + code: number + result: T + message?: string +} + +export type HttpMethod = 'GET' | 'POST' | 'PUT' | 'DELETE' + +export interface RequestOptions { + /** 是否显示 loading,默认 true */ + loading?: boolean + /** loading 文案,默认"加载中..." */ + loadingTitle?: string + /** Content-Type,默认 application/json */ + contentType?: 'application/json' | 'application/x-www-form-urlencoded' +}