diff --git a/admin-console/src/components/ConfirmModal.vue b/admin-console/src/components/ConfirmModal.vue
new file mode 100644
index 0000000..3b34f93
--- /dev/null
+++ b/admin-console/src/components/ConfirmModal.vue
@@ -0,0 +1,58 @@
+
+
+
+ {{ title }}
+
+
+
+
+
+
+
+
+
+
+
+
diff --git a/admin-console/src/components/DataTable.vue b/admin-console/src/components/DataTable.vue
new file mode 100644
index 0000000..c6e8a9b
--- /dev/null
+++ b/admin-console/src/components/DataTable.vue
@@ -0,0 +1,57 @@
+
+
+
+
+
+
+
+
+ 暂无数据
+
+
+
+
+
+
+
+
+
+
diff --git a/admin-console/src/pages/device/index.vue b/admin-console/src/pages/device/index.vue
index 24d71be..513c88a 100644
--- a/admin-console/src/pages/device/index.vue
+++ b/admin-console/src/pages/device/index.vue
@@ -16,61 +16,46 @@
-
-
-
+
+
-
-
-
-
- 批量导入设备
-
- 设备编号(每行一个)
-
-
- 最多 500 个设备
-
-
-
-
-
-
+ 最多 500 个设备
+
@@ -78,54 +63,48 @@
import { get, post } from '../../utils/request'
import { exportCSV } from '../../utils/export'
import { formatDateShort } from '../../utils/format'
+import { listMixin } from '../../utils/useList'
import AdminLayout from '../../components/AdminLayout.vue'
+import DataTable from '../../components/DataTable.vue'
+import ConfirmModal from '../../components/ConfirmModal.vue'
+
+var STATUS_MAP = { 1: '未激活', 2: '在线', 3: '离线', 4: '故障' }
+var STATUS_BADGE = { 2: 'badge badge-success', 3: 'badge badge-warning', 1: 'badge badge-blue', 4: 'badge badge-error' }
export default {
- components: { AdminLayout },
+ components: { AdminLayout, DataTable, ConfirmModal },
+ mixins: [listMixin('/api/v1/admin/devices')],
data() {
return {
- devices: [],
- total: 0,
- page: 1,
- pageSize: 20,
keyword: '',
- statusMap: { 1: '未激活', 2: '在线', 3: '离线', 4: '故障' },
+ statusMap: STATUS_MAP,
+ columns: [
+ { key: 'device_id', label: '设备编号', flex: 2 },
+ { key: 'product_id', label: '产品编号', flex: 2 },
+ { key: 'bound_user', label: '绑定用户', flex: 2 },
+ { key: 'battery', label: '电量', flex: 1 },
+ { key: 'fw', label: '固件版本', flex: 1 },
+ { key: 'activated_at', label: '绑定时间', flex: 2 },
+ { key: 'status', label: '状态', flex: 1 },
+ { key: 'actions', label: '操作', flex: 1 }
+ ],
showBatchImport: false,
batchDeviceIds: '',
batchImporting: false
}
},
- computed: {
- totalPages() {
- return Math.ceil(this.total / this.pageSize) || 1
- }
- },
onShow() {
- this.loadDevices()
+ this.reload()
},
methods: {
- async loadDevices() {
- try {
- const data = await get('/api/v1/admin/devices', { page: this.page, page_size: this.pageSize, keyword: this.keyword })
- this.devices = data.records || []
- this.total = data.total || 0
- } catch (e) {
- uni.showToast({ title: '加载失败', icon: 'none' })
- }
- },
- onSearch() {
- this.page = 1
- this.loadDevices()
- },
- onPage(p) {
- this.page = p
- this.loadDevices()
+ reload() {
+ this.loadList({ keyword: this.keyword })
},
onDetail(deviceId) {
uni.navigateTo({ url: '/pages/device-detail/index?device_id=' + deviceId })
},
onCreateDevice() {
- const self = this
+ var self = this
uni.showModal({
title: '预生成产品码',
editable: true,
@@ -135,7 +114,7 @@ export default {
try {
await post('/api/v1/admin/devices', { device_id: res.content.trim(), product_id: 'HOX_LIGHT_MASK' })
uni.showToast({ title: '创建成功', icon: 'success' })
- self.loadDevices()
+ self.reload()
} catch (e) {
uni.showToast({ title: '创建失败', icon: 'none' })
}
@@ -143,7 +122,7 @@ export default {
})
},
onUnbind(item) {
- const self = this
+ var self = this
uni.showModal({
title: '确认解绑',
content: '确定要解绑设备 ' + item.device_id + ' 吗?',
@@ -152,7 +131,7 @@ export default {
try {
await post('/api/v1/admin/devices/' + item.device_id + '/unbind', {})
uni.showToast({ title: '解绑成功', icon: 'success' })
- self.loadDevices()
+ self.reload()
} catch (e) {
uni.showToast({ title: '解绑失败', icon: 'none' })
}
@@ -161,12 +140,11 @@ export default {
})
},
statusBadge(status) {
- const map = { 2: 'badge badge-success', 3: 'badge badge-warning', 1: 'badge badge-blue', 4: 'badge badge-error' }
- return map[status] || 'badge badge-default'
+ return STATUS_BADGE[status] || 'badge badge-default'
},
formatDate: formatDateShort,
async onBatchImport() {
- const ids = this.batchDeviceIds.split('\n').map(s => s.trim()).filter(Boolean)
+ var ids = this.batchDeviceIds.split('\n').map(function (s) { return s.trim() }).filter(Boolean)
if (ids.length === 0) {
uni.showToast({ title: '请输入设备编号', icon: 'none' })
return
@@ -177,11 +155,11 @@ export default {
}
this.batchImporting = true
try {
- const result = await post('/api/v1/admin/devices/batch', { device_ids: ids })
+ var result = await post('/api/v1/admin/devices/batch', { device_ids: ids })
uni.showToast({ title: '导入 ' + result.created + ' 个设备', icon: 'success' })
this.showBatchImport = false
this.batchDeviceIds = ''
- this.loadDevices()
+ this.reload()
} catch (e) {
uni.showToast({ title: '导入失败', icon: 'none' })
} finally {
@@ -190,8 +168,8 @@ export default {
},
async onExport() {
try {
- const data = await get('/api/v1/admin/devices', { page: 1, page_size: 9999, keyword: this.keyword })
- const records = data.records || []
+ var data = await get('/api/v1/admin/devices', { page: 1, page_size: 9999, keyword: this.keyword })
+ var records = data.records || []
exportCSV('devices_' + new Date().toISOString().slice(0, 10) + '.csv',
['设备编号', '产品编号', '绑定用户', '电量', '固件版本', '绑定时间', '状态'],
records.map(function (r) {
@@ -210,64 +188,9 @@ export default {
diff --git a/admin-console/src/pages/subscription/index.vue b/admin-console/src/pages/subscription/index.vue
index a44ccbb..0d73172 100644
--- a/admin-console/src/pages/subscription/index.vue
+++ b/admin-console/src/pages/subscription/index.vue
@@ -28,76 +28,64 @@
-
-
- 全部订阅
- 月卡
- 年卡
- 试用中
- 已过期
-
+
+
+
+ 全部订阅
+ 月卡
+ 年卡
+ 试用中
+ 已过期
+
+
-
-
+
+
-
-
-
-
- 创建订阅
-
- 用户ID
-
-
-
- 方案
-
-
-
- 天数
-
-
-
-
-
-
+
+ 方案
+
-
+
+ 天数
+
+
+
@@ -105,50 +93,50 @@
import { get, post } from '../../utils/request'
import { exportCSV } from '../../utils/export'
import { formatDateShort } from '../../utils/format'
+import { listMixin } from '../../utils/useList'
import AdminLayout from '../../components/AdminLayout.vue'
+import DataTable from '../../components/DataTable.vue'
+import ConfirmModal from '../../components/ConfirmModal.vue'
+
+var PLAN_MAP = { monthly: '月卡', quarterly: '季卡', yearly: '年卡', trial: '试用' }
+var STATUS_TEXT = { 1: '生效中', 2: '已过期', 3: '已取消' }
+var STATUS_BADGE = { 1: 'badge badge-success', 2: 'badge badge-error', 3: 'badge badge-default' }
export default {
- components: { AdminLayout },
+ components: { AdminLayout, DataTable, ConfirmModal },
+ mixins: [listMixin('/api/v1/admin/subscriptions')],
data() {
return {
- subscriptions: [],
- total: 0,
- page: 1,
- pageSize: 20,
stats: {},
+ activeTab: 'all',
+ columns: [
+ { key: 'user', label: '用户', flex: 2 },
+ { key: 'plan', label: '订阅类型', flex: 1 },
+ { key: 'amount', label: '订单金额', flex: 1 },
+ { key: 'start', label: '开始日期', flex: 2 },
+ { key: 'expire', label: '到期日期', flex: 2 },
+ { key: 'status', label: '状态', flex: 1 },
+ { key: 'actions', label: '操作', flex: 1 }
+ ],
showCreate: false,
creating: false,
- createForm: { user_id: '', plan: 'monthly', days: 30 },
- activeTab: 'all'
- }
- },
- computed: {
- totalPages() {
- return Math.ceil(this.total / this.pageSize) || 1
+ createForm: { user_id: '', plan: 'monthly', days: 30 }
}
},
onShow() {
- this.loadSubscriptions()
+ this.reload()
},
methods: {
- async loadSubscriptions() {
- try {
- const data = await get('/api/v1/admin/subscriptions', { page: this.page, page_size: this.pageSize, tab: this.activeTab })
- this.subscriptions = data.records || []
- this.total = data.total || 0
- if (data.stats) this.stats = data.stats
- } catch (e) {
- uni.showToast({ title: '加载失败', icon: 'none' })
- }
+ reload() {
+ this.loadList({ tab: this.activeTab })
+ },
+ onListLoaded(data) {
+ if (data.stats) this.stats = data.stats
},
onTabFilter(tab) {
this.activeTab = tab
this.page = 1
- this.loadSubscriptions()
- },
- onPage(p) {
- this.page = p
- this.loadSubscriptions()
+ this.reload()
},
async onCreate() {
this.creating = true
@@ -159,7 +147,7 @@ export default {
days: parseInt(this.createForm.days)
})
this.showCreate = false
- this.loadSubscriptions()
+ this.reload()
uni.showToast({ title: '创建成功', icon: 'success' })
} catch (e) {
uni.showToast({ title: '创建失败', icon: 'none' })
@@ -186,7 +174,7 @@ export default {
days: 30
})
uni.showToast({ title: '延期成功', icon: 'success' })
- self.loadSubscriptions()
+ self.reload()
} catch (e) {
uni.showToast({ title: '操作失败', icon: 'none' })
}
@@ -206,7 +194,7 @@ export default {
subscription_id: item.subscription_id
})
uni.showToast({ title: '已取消', icon: 'success' })
- self.loadSubscriptions()
+ self.reload()
} catch (e) {
uni.showToast({ title: '操作失败', icon: 'none' })
}
@@ -220,29 +208,18 @@ export default {
this.createForm.days = 30
this.showCreate = true
},
- planText(plan) {
- const map = { monthly: '月卡', quarterly: '季卡', yearly: '年卡', trial: '试用' }
- return map[plan] || plan || '-'
- },
- statusText(status) {
- const map = { 1: '生效中', 2: '已过期', 3: '已取消' }
- return map[status] || '-'
- },
- statusBadge(status) {
- const map = { 1: 'badge badge-success', 2: 'badge badge-error', 3: 'badge badge-default' }
- return map[status] || 'badge badge-default'
- },
+ planText(plan) { return PLAN_MAP[plan] || plan || '-' },
+ statusText(status) { return STATUS_TEXT[status] || '-' },
+ statusBadge(status) { return STATUS_BADGE[status] || 'badge badge-default' },
formatDate: formatDateShort,
async onExport() {
try {
- const data = await get('/api/v1/admin/subscriptions', { page: 1, page_size: 9999, tab: this.activeTab })
- const records = data.records || []
- var planMap = { monthly: '月卡', quarterly: '季卡', yearly: '年卡', trial: '试用' }
- var statusMap = { 1: '生效中', 2: '已过期', 3: '已取消' }
+ var data = await get('/api/v1/admin/subscriptions', { page: 1, page_size: 9999, tab: this.activeTab })
+ var records = data.records || []
exportCSV('subscriptions_' + new Date().toISOString().slice(0, 10) + '.csv',
['用户', '订阅类型', '订单金额', '开始日期', '到期日期', '状态'],
records.map(function (r) {
- return [r.nickname || r.user_id || '', planMap[r.plan] || r.plan || '', r.amount || '', r.start_time ? String(r.start_time).slice(0, 10) : '', r.expire_time ? String(r.expire_time).slice(0, 10) : '', statusMap[r.status] || '']
+ return [r.nickname || r.user_id || '', PLAN_MAP[r.plan] || r.plan || '', r.amount || '', r.start_time ? String(r.start_time).slice(0, 10) : '', r.expire_time ? String(r.expire_time).slice(0, 10) : '', STATUS_TEXT[r.status] || '']
})
)
uni.showToast({ title: '导出成功', icon: 'success' })
@@ -308,58 +285,4 @@ export default {
.action-link {
margin-right: 8px;
}
-
-.modal-mask {
- position: fixed;
- top: 0;
- left: 0;
- right: 0;
- bottom: 0;
- background: rgba(0, 0, 0, 0.45);
- display: flex;
- align-items: center;
- justify-content: center;
- z-index: 1000;
-}
-
-.modal-content {
- width: 440px;
- background: #fff;
- border-radius: 8px;
- padding: 24px;
-}
-
-.modal-title {
- font-size: 18px;
- font-weight: 600;
- margin-bottom: 20px;
-}
-
-.form-group {
- margin-bottom: 16px;
-}
-
-.form-label {
- display: block;
- font-size: 14px;
- color: #333;
- margin-bottom: 6px;
-}
-
-.form-input {
- width: 100%;
- height: 36px;
- border: 1px solid #d9d9d9;
- border-radius: 6px;
- padding: 0 12px;
- font-size: 14px;
- box-sizing: border-box;
-}
-
-.modal-actions {
- display: flex;
- justify-content: flex-end;
- gap: 8px;
- margin-top: 20px;
-}
diff --git a/admin-console/src/styles/common.css b/admin-console/src/styles/common.css
index 2adc9b6..ab957c7 100644
--- a/admin-console/src/styles/common.css
+++ b/admin-console/src/styles/common.css
@@ -171,3 +171,35 @@
.input-placeholder {
color: #bfbfbf;
}
+
+.form-group {
+ margin-bottom: 16px;
+}
+
+.form-label {
+ display: block;
+ font-size: 14px;
+ color: #333;
+ margin-bottom: 6px;
+}
+
+.form-input {
+ width: 100%;
+ height: 36px;
+ border: 1px solid #d9d9d9;
+ border-radius: 6px;
+ padding: 0 12px;
+ font-size: 14px;
+ box-sizing: border-box;
+}
+
+.form-textarea {
+ width: 100%;
+ height: 160px;
+ border: 1px solid #d9d9d9;
+ border-radius: 6px;
+ padding: 12px;
+ font-size: 14px;
+ box-sizing: border-box;
+ resize: vertical;
+}
diff --git a/admin-console/src/utils/useList.js b/admin-console/src/utils/useList.js
new file mode 100644
index 0000000..2659561
--- /dev/null
+++ b/admin-console/src/utils/useList.js
@@ -0,0 +1,81 @@
+import { get } from './request'
+
+/**
+ * Creates a mixin for paginated list pages.
+ *
+ * Eliminates the repeated data/computed/methods boilerplate for:
+ * - records[], total, page, pageSize, totalPages
+ * - loadList(), onPage(), onSearch()
+ *
+ * Usage:
+ * import { listMixin } from '../../utils/useList'
+ *
+ * export default {
+ * mixins: [listMixin('/api/v1/admin/devices')],
+ * methods: {
+ * reload() {
+ * this.loadList({ keyword: this.keyword })
+ * }
+ * }
+ * }
+ *
+ * @param {string} apiPath - API endpoint path (passed to get())
+ * @param {object} opts
+ * @param {number} [opts.pageSize=20] - Items per page
+ */
+export function listMixin(apiPath, opts = {}) {
+ return {
+ data() {
+ return {
+ records: [],
+ total: 0,
+ page: 1,
+ pageSize: opts.pageSize || 20
+ }
+ },
+ computed: {
+ totalPages() {
+ return Math.ceil(this.total / this.pageSize) || 1
+ }
+ },
+ methods: {
+ async loadList(extraParams = {}) {
+ try {
+ var params = { page: this.page, page_size: this.pageSize }
+ // Merge extra params, dropping falsy values to keep request clean
+ var keys = Object.keys(extraParams)
+ for (var i = 0; i < keys.length; i++) {
+ var k = keys[i]
+ if (extraParams[k] !== '' && extraParams[k] != null) {
+ params[k] = extraParams[k]
+ }
+ }
+ var data = await get(apiPath, params)
+ this.records = data.records || []
+ this.total = data.total || 0
+ this.onListLoaded(data)
+ } catch (e) {
+ uni.showToast({ title: '加载失败', icon: 'none' })
+ }
+ },
+ /**
+ * Override in page to handle extra response data (e.g. stats).
+ */
+ onListLoaded(_data) {},
+ onPage(p) {
+ this.page = p
+ this.reload()
+ },
+ onSearch() {
+ this.page = 1
+ this.reload()
+ },
+ /**
+ * Override in page to call loadList() with current filters.
+ */
+ reload() {
+ this.loadList()
+ }
+ }
+ }
+}
diff --git a/miniprogram/pages/index/index.js b/miniprogram/pages/index/index.js
index fe65b30..07d5009 100644
--- a/miniprogram/pages/index/index.js
+++ b/miniprogram/pages/index/index.js
@@ -1,5 +1,5 @@
var ble = require('../../services/ble')
-var http = require('../../utils/request')
+var api = require('../../utils/api')
var config = require('../../config/env')
var app = getApp()
@@ -49,7 +49,7 @@ Page({
self.setData({ connected: ble.isConnected() })
- http.get('/api/v1/device/list').then(function (data) {
+ api.getDevices().then(function (data) {
var devices = data.devices || []
self.setData({ hasDevice: devices.length > 0 })
if (devices.length > 0) {
@@ -61,7 +61,7 @@ Page({
}
}).catch(function () {})
- http.get('/api/v1/subscription').then(function (sub) {
+ api.getSubscription().then(function (sub) {
self.setData({
subscription: sub,
subRemaining: sub.remaining_days || 0
@@ -125,7 +125,7 @@ Page({
var self = this
wx.showLoading({ title: '解绑中...' })
ble.disconnect()
- http.post('/api/v1/device/unbind', {}).then(function () {
+ api.unbindDevice().then(function () {
wx.hideLoading()
self.setData({
hasDevice: false,
@@ -157,7 +157,7 @@ Page({
success: function (res) {
if (res.confirm && res.content) {
var deviceId = res.content.trim()
- http.post('/api/v1/device/mock-bind', { device_id: deviceId }).then(function () {
+ api.mockBind(deviceId).then(function () {
wx.showToast({ title: '模拟绑定成功', icon: 'success' })
self.checkState()
}).catch(function (err) {
diff --git a/miniprogram/pages/profile/profile.js b/miniprogram/pages/profile/profile.js
index 972ea12..ef43c83 100644
--- a/miniprogram/pages/profile/profile.js
+++ b/miniprogram/pages/profile/profile.js
@@ -1,4 +1,4 @@
-var http = require('../../utils/request')
+var api = require('../../utils/api')
var app = getApp()
Page({
@@ -15,14 +15,14 @@ Page({
loadProfile: function () {
var self = this
- http.get('/api/v1/user/profile').then(function (profile) {
+ api.getProfile().then(function (profile) {
self.setData({
userInfo: profile,
deviceCount: profile.device_count || 0
})
}).catch(function () {})
- http.get('/api/v1/subscription').then(function (sub) {
+ api.getSubscription().then(function (sub) {
self.setData({
subscription: sub,
subRemaining: sub.remaining_days || 0
@@ -42,7 +42,7 @@ Page({
content: '解绑后将无法使用该设备,确定要解绑吗?',
success: function (modalRes) {
if (modalRes.confirm) {
- http.post('/api/v1/device/unbind', {}).then(function () {
+ api.unbindDevice().then(function () {
wx.showToast({ title: '已解绑', icon: 'success' })
self.loadProfile()
}).catch(function (err) {
diff --git a/miniprogram/services/ble.js b/miniprogram/services/ble.js
deleted file mode 100644
index cb25910..0000000
--- a/miniprogram/services/ble.js
+++ /dev/null
@@ -1,734 +0,0 @@
-var SERVICE = {
- DEVICE_INFO: 'FFE0',
- DATA_COMM: 'FFE1',
- OTA: 'FFE2'
-}
-
-var CHAR = {
- DEVICE_INFO: 'FFE3',
- COMMAND: 'FFE4',
- STATUS: 'FFE5',
- BOND_INFO: 'FFE6',
- OTA_CONTROL: 'FFE7',
- OTA_DATA: 'FFE8',
- OTA_STATUS: 'FFE9'
-}
-
-var CMD = {
- SET_PARAMS: 0x01,
- START: 0x02,
- STOP: 0x03,
- QUERY_STATUS: 0x04,
- BIND: 0x05,
- UNBIND: 0x06
-}
-
-var NOTIFY = {
- STATUS_REPORT: 0x21,
- ACK: 0x22,
- TREATMENT_COMPLETE: 0x31,
- EXCEPTION: 0x32,
- BIND_SUCCESS: 0x33
-}
-
-var MODE_STATE = {
- IDLE: 0x00,
- SCANNING: 0x01,
- ACTIVE: 0x02,
- PAUSED: 0x03,
- COMPLETED: 0x04,
- ERROR: 0x05,
- OTA: 0x06
-}
-
-var WAVELENGTH = {
- IR: 1,
- R: 2,
- UV: 3,
- Y: 4
-}
-
-var TREAT_MODE = {
- NORMAL: 0,
- SMART: 1
-}
-
-var REGION = {
- LEFT_CHEEK: 0x01,
- RIGHT_CHEEK: 0x02,
- FOREHEAD: 0x04,
- CHIN: 0x08,
- NOSE: 0x10,
- LEFT_EYE: 0x20,
- RIGHT_EYE: 0x40,
- FULL_FACE: 0x7F
-}
-
-var REGION_NAMES = ['left_cheek', 'right_cheek', 'forehead', 'chin', 'nose', 'left_eye', 'right_eye']
-
-var DEVICE_ERR = {
- 0x00: 'SUCCESS',
- 0x01: 'ERR_REGION_INVALID',
- 0x02: 'ERR_REGION_EMPTY',
- 0x03: 'ERR_BRIGHTNESS_INVALID',
- 0x04: 'ERR_DURATION_INVALID',
- 0x05: 'ERR_NOT_BOUND',
- 0x06: 'ERR_NO_SUBSCRIPTION',
- 0x07: 'ERR_TEMP_HIGH',
- 0x08: 'ERR_BATTERY_LOW',
- 0x09: 'ERR_ALREADY_RUNNING',
- 0x0A: 'ERR_NOT_RUNNING',
- 0x0B: 'ERR_OTA_FAILED',
- 0x0C: 'ERR_BLE_DISCONNECTED'
-}
-
-var _deviceId = null
-var _connected = false
-var _chars = {}
-var _cmdSeq = 0
-var _pendingAcks = {}
-var _listeners = {}
-var _autoReconnect = true
-var _reconnecting = false
-
-function nextSeq() {
- _cmdSeq = (_cmdSeq + 1) % 256
- return _cmdSeq
-}
-
-function bufferToBytes(buffer) {
- var arr = new Uint8Array(buffer)
- var bytes = []
- for (var i = 0; i < arr.length; i++) {
- bytes.push(arr[i])
- }
- return bytes
-}
-
-function bytesToBuffer(bytes) {
- var buffer = new ArrayBuffer(bytes.length)
- var view = new Uint8Array(buffer)
- for (var i = 0; i < bytes.length; i++) {
- view[i] = bytes[i]
- }
- return buffer
-}
-
-function xorChecksum(bytes) {
- var result = 0
- for (var i = 0; i < bytes.length; i++) {
- result ^= bytes[i]
- }
- return result
-}
-
-function buildFrame(type, payload) {
- var len = payload ? payload.length : 0
- var frame = [0xAA, 0x55, len, type]
- if (payload && payload.length > 0) {
- frame = frame.concat(payload)
- }
- var checkBytes = frame.slice(0)
- frame.push(xorChecksum(checkBytes))
- return bytesToBuffer(frame)
-}
-
-function parseFrame(buffer) {
- var bytes = bufferToBytes(buffer)
- if (bytes.length < 5) return null
- if (bytes[0] !== 0xAA || bytes[1] !== 0x55) return null
- var len = bytes[2]
- if (bytes.length < 5 + len) return null
- var type = bytes[3]
- var payload = bytes.slice(4, 4 + len)
- var checksum = bytes[4 + len]
- var expected = xorChecksum(bytes.slice(0, 4 + len))
- if (checksum !== expected) return null
- return { type: type, payload: payload, seq: payload.length > 0 ? payload[payload.length - 1] : 0 }
-}
-
-function uint32ToBytes(value) {
- return [
- (value >> 24) & 0xFF,
- (value >> 16) & 0xFF,
- (value >> 8) & 0xFF,
- value & 0xFF
- ]
-}
-
-function bytesToUint32(bytes, offset) {
- return (bytes[offset] << 24) | (bytes[offset + 1] << 16) | (bytes[offset + 2] << 8) | bytes[offset + 3]
-}
-
-function hexToBytes(hex) {
- var bytes = []
- for (var i = 0; i < hex.length; i += 2) {
- bytes.push(parseInt(hex.substr(i, 2), 16))
- }
- return bytes
-}
-
-function bytesToHex(bytes) {
- var hex = ''
- for (var i = 0; i < bytes.length; i++) {
- hex += ('0' + bytes[i].toString(16)).slice(-2)
- }
- return hex.toUpperCase()
-}
-
-function findCharUuid(chars, shortUuid) {
- for (var i = 0; i < chars.length; i++) {
- if (chars[i].uuid.indexOf(shortUuid) !== -1) {
- return chars[i].uuid
- }
- }
- return null
-}
-
-function on(event, callback) {
- if (!_listeners[event]) _listeners[event] = []
- _listeners[event].push(callback)
-}
-
-function off(event, callback) {
- if (!_listeners[event]) return
- if (callback) {
- _listeners[event] = _listeners[event].filter(function (cb) { return cb !== callback })
- } else {
- _listeners[event] = []
- }
-}
-
-function emit(event, data) {
- if (!_listeners[event]) return
- _listeners[event].forEach(function (cb) {
- try { cb(data) } catch (e) { console.error('ble emit error:', e) }
- })
-}
-
-function handleNotification(frame) {
- switch (frame.type) {
- case NOTIFY.STATUS_REPORT:
- emit('status', parseStatusReport(frame.payload))
- break
- case NOTIFY.ACK:
- var ack = parseAck(frame.payload)
- emit('ack', ack)
- if (_pendingAcks[ack.seq]) {
- if (ack.error_code === 0) {
- _pendingAcks[ack.seq].resolve(ack)
- } else {
- _pendingAcks[ack.seq].reject(ack)
- }
- delete _pendingAcks[ack.seq]
- }
- break
- case NOTIFY.TREATMENT_COMPLETE:
- emit('treatment_complete', parseTreatmentComplete(frame.payload))
- break
- case NOTIFY.EXCEPTION:
- emit('exception', parseException(frame.payload))
- break
- case NOTIFY.BIND_SUCCESS:
- emit('bind_result', { success: frame.payload[0] === 0x00 })
- break
- }
-}
-
-function parseStatusReport(payload) {
- if (payload.length < 14) return null
- return {
- mode_state: payload[0],
- region_mask: payload[1],
- wavelength: payload[2],
- brightness: payload[3],
- remaining_ms: bytesToUint32(payload, 4),
- error_code: payload[8],
- command_seq: payload[9],
- battery: payload[10],
- temperature: payload[11],
- bind_status: payload[12],
- subscription: payload[13]
- }
-}
-
-function parseAck(payload) {
- return {
- seq: payload[0],
- error_code: payload.length > 1 ? payload[1] : 0,
- error_msg: DEVICE_ERR[payload.length > 1 ? payload[1] : 0] || 'UNKNOWN'
- }
-}
-
-function parseTreatmentComplete(payload) {
- return {
- session_id: bytesToHex(payload.slice(0, 8)),
- regions: payload[8],
- total_duration_ms: bytesToUint32(payload, 9),
- avg_pd: payload[13]
- }
-}
-
-function parseException(payload) {
- return {
- error_code: payload[0],
- error_msg: DEVICE_ERR[payload[0]] || 'UNKNOWN',
- temperature: payload.length > 1 ? payload[1] : 0
- }
-}
-
-function isConnected() {
- return _connected && _deviceId !== null
-}
-
-function getDeviceId() {
- return _deviceId
-}
-
-function startScan(callbacks) {
- wx.openBluetoothAdapter({
- success: function () {
- wx.startBluetoothDevicesDiscovery({
- allowDuplicatesKey: false,
- success: function () {
- wx.offBluetoothDeviceFound()
- wx.onBluetoothDeviceFound(function (res) {
- var devices = res.devices || []
- for (var i = 0; i < devices.length; i++) {
- var d = devices[i]
- var name = (d.name || '').toUpperCase()
- var localName = (d.localName || '').toUpperCase()
- if (name.indexOf('HOX') !== -1 || localName.indexOf('HOX') !== -1 ||
- name.indexOf('LIGHTMASK') !== -1 || localName.indexOf('LIGHTMASK') !== -1) {
- wx.stopBluetoothDevicesDiscovery({})
- if (callbacks.onFound) callbacks.onFound(d)
- connect(d.deviceId, callbacks)
- return
- }
- }
- })
- },
- fail: function () {
- if (callbacks.onError) callbacks.onError({ msg: '扫描失败' })
- }
- })
- },
- fail: function () {
- if (callbacks.onError) callbacks.onError({ msg: '请开启蓝牙' })
- }
- })
-}
-
-function connect(deviceId, callbacks) {
- _deviceId = deviceId
- _chars = {}
-
- wx.createBLEConnection({
- deviceId: deviceId,
- timeout: 10000,
- success: function () {
- _connected = true
- discoverServices(deviceId, callbacks)
- },
- fail: function () {
- _connected = false
- if (callbacks.onError) callbacks.onError({ msg: '连接失败' })
- }
- })
-}
-
-function discoverServices(deviceId, callbacks) {
- wx.getBLEDeviceServices({
- deviceId: deviceId,
- success: function (res) {
- var services = res.services
- var serviceMap = {}
- for (var i = 0; i < services.length; i++) {
- var uuid = services[i].uuid.toUpperCase()
- if (uuid.indexOf(SERVICE.DEVICE_INFO) !== -1) {
- serviceMap.deviceInfo = services[i].uuid
- } else if (uuid.indexOf(SERVICE.DATA_COMM) !== -1) {
- serviceMap.dataComm = services[i].uuid
- } else if (uuid.indexOf(SERVICE.OTA) !== -1) {
- serviceMap.ota = services[i].uuid
- }
- }
-
- var tasks = []
- if (serviceMap.deviceInfo) {
- tasks.push(discoverChars(deviceId, serviceMap.deviceInfo, 'deviceInfo'))
- }
- if (serviceMap.dataComm) {
- tasks.push(discoverChars(deviceId, serviceMap.dataComm, 'dataComm'))
- }
-
- Promise.all(tasks).then(function () {
- subscribeToNotifications(deviceId, serviceMap.dataComm).then(function () {
- if (callbacks.onConnected) callbacks.onConnected({ deviceId: deviceId })
- })
- })
- },
- fail: function () {
- if (callbacks.onError) callbacks.onError({ msg: '服务发现失败' })
- }
- })
-}
-
-function discoverChars(deviceId, serviceId, group) {
- return new Promise(function (resolve) {
- wx.getBLEDeviceCharacteristics({
- deviceId: deviceId,
- serviceId: serviceId,
- success: function (res) {
- var chars = res.characteristics || []
- for (var i = 0; i < chars.length; i++) {
- var c = chars[i]
- var uuid = c.uuid.toUpperCase()
- if (uuid.indexOf(CHAR.DEVICE_INFO) !== -1) _chars.deviceInfo = { uuid: c.uuid, serviceId: serviceId }
- if (uuid.indexOf(CHAR.COMMAND) !== -1) _chars.command = { uuid: c.uuid, serviceId: serviceId }
- if (uuid.indexOf(CHAR.STATUS) !== -1) _chars.status = { uuid: c.uuid, serviceId: serviceId }
- if (uuid.indexOf(CHAR.BOND_INFO) !== -1) _chars.bondInfo = { uuid: c.uuid, serviceId: serviceId }
- if (uuid.indexOf(CHAR.OTA_CONTROL) !== -1) _chars.otaControl = { uuid: c.uuid, serviceId: serviceId }
- if (uuid.indexOf(CHAR.OTA_DATA) !== -1) _chars.otaData = { uuid: c.uuid, serviceId: serviceId }
- if (uuid.indexOf(CHAR.OTA_STATUS) !== -1) _chars.otaStatus = { uuid: c.uuid, serviceId: serviceId }
- }
- resolve()
- },
- fail: function () { resolve() }
- })
- })
-}
-
-function subscribeToNotifications(deviceId, serviceId) {
- return new Promise(function (resolve) {
- if (!_chars.status) { resolve(); return }
-
- wx.notifyBLECharacteristicValueChange({
- deviceId: deviceId,
- serviceId: _chars.status.serviceId,
- characteristicId: _chars.status.uuid,
- state: true,
- success: function () {
- wx.onBLECharacteristicValueChange(function (res) {
- var frame = parseFrame(res.value)
- if (frame) handleNotification(frame)
- })
- resolve()
- },
- fail: function () { resolve() }
- })
- })
-}
-
-function writeCommand(type, payload) {
- return new Promise(function (resolve, reject) {
- if (!_connected || !_deviceId) {
- reject({ error_code: 0x0C, error_msg: 'ERR_BLE_DISCONNECTED' })
- return
- }
- if (!_chars.command) {
- reject({ error_code: 0xFF, error_msg: 'command characteristic not found' })
- return
- }
-
- var seq = nextSeq()
- var payloadWithSeq = (payload || []).concat([seq])
- var buffer = buildFrame(type, payloadWithSeq)
-
- _pendingAcks[seq] = { resolve: resolve, reject: reject }
-
- setTimeout(function () {
- if (_pendingAcks[seq]) {
- _pendingAcks[seq].reject({ error_code: 0xFF, error_msg: 'ACK timeout' })
- delete _pendingAcks[seq]
- }
- }, 5000)
-
- wx.writeBLECharacteristicValue({
- deviceId: _deviceId,
- serviceId: _chars.command.serviceId,
- characteristicId: _chars.command.uuid,
- value: buffer,
- success: function () {},
- fail: function () {
- delete _pendingAcks[seq]
- reject({ error_code: 0x0C, error_msg: 'ERR_BLE_DISCONNECTED' })
- }
- })
- })
-}
-
-function writeCommandWithRetry(type, payload, maxRetries) {
- if (maxRetries === undefined) maxRetries = 3
- var attempt = 0
- function tryOnce() {
- attempt++
- return writeCommand(type, payload).catch(function (err) {
- if (err && err.error_code === 0x0C) {
- return Promise.reject(err)
- }
- if (attempt >= maxRetries) {
- return Promise.reject(err)
- }
- return new Promise(function (resolve) {
- setTimeout(resolve, 500)
- }).then(function () {
- return tryOnce()
- })
- })
- }
- return tryOnce()
-}
-
-function readDeviceInfo() {
- return new Promise(function (resolve, reject) {
- if (!_connected || !_deviceId || !_chars.deviceInfo) {
- reject({ msg: '设备未连接或特征值未就绪' })
- return
- }
- wx.readBLECharacteristicValue({
- deviceId: _deviceId,
- serviceId: _chars.deviceInfo.serviceId,
- characteristicId: _chars.deviceInfo.uuid,
- success: function () {},
- fail: function () { reject({ msg: '读取设备信息失败' }) }
- })
-
- var handler = function (res) {
- if (res.characteristicId.toUpperCase().indexOf(CHAR.DEVICE_INFO) !== -1) {
- wx.offBLECharacteristicValueChange(handler)
- var bytes = bufferToBytes(res.value)
- if (bytes.length >= 14) {
- resolve({
- hw_version: (bytes[0] << 8) | bytes[1],
- fw_version: (bytes[2] << 8) | bytes[3],
- device_type: (bytes[4] << 8) | bytes[5],
- device_id: bytesToHex(bytes.slice(6, 14))
- })
- } else {
- reject({ msg: '设备信息格式错误' })
- }
- }
- }
- wx.onBLECharacteristicValueChange(handler)
- })
-}
-
-function setParams(options) {
- var regionMask = options.region_mask || REGION.FULL_FACE
- var wavelength = options.wavelength || WAVELENGTH.R
- var brightness = options.brightness || 200
- var durationMs = options.duration_ms || 600000
- var mode = options.mode !== undefined ? options.mode : TREAT_MODE.NORMAL
-
- var payload = [
- regionMask,
- wavelength,
- brightness,
- uint32ToBytes(durationMs),
- mode
- ].reduce(function (a, b) {
- return a.concat(Array.isArray(b) ? b : [b])
- }, [])
-
- return writeCommandWithRetry(CMD.SET_PARAMS, payload)
-}
-
-function startTreatment(regionMask) {
- var mask = regionMask || REGION.FULL_FACE
- return writeCommandWithRetry(CMD.START, [mask])
-}
-
-function stopTreatment() {
- return writeCommandWithRetry(CMD.STOP, [])
-}
-
-function queryStatus() {
- return writeCommandWithRetry(CMD.QUERY_STATUS, [])
-}
-
-function bindDevice(userId, bindToken) {
- var userBytes = hexToBytes(userId)
- var tokenBytes = hexToBytes(bindToken)
- var ts = Math.floor(Date.now() / 1000)
- var tsBytes = uint32ToBytes(ts)
-
- var payload = [0x01].concat(userBytes).concat(tokenBytes).concat(tsBytes)
- return writeCommandWithRetry(CMD.BIND, payload)
-}
-
-function unbindDevice(userId) {
- var userBytes = hexToBytes(userId)
- var payload = [0x02].concat(userBytes)
- return writeCommandWithRetry(CMD.UNBIND, payload)
-}
-
-function stopScan() {
- wx.stopBluetoothDevicesDiscovery({})
- wx.offBluetoothDeviceFound()
-}
-
-function disconnect() {
- _autoReconnect = false
- _reconnecting = false
- if (_deviceId) {
- wx.closeBLEConnection({ deviceId: _deviceId })
- _deviceId = null
- }
- _connected = false
- _chars = {}
- _pendingAcks = {}
- _listeners = {}
- wx.closeBluetoothAdapter({})
-}
-
-function attemptReconnect(deviceId, retriesLeft) {
- wx.createBLEConnection({
- deviceId: deviceId,
- timeout: 10000,
- success: function () {
- _connected = true
- _chars = {}
- wx.getBLEDeviceServices({
- deviceId: deviceId,
- success: function (res) {
- var services = res.services
- var serviceMap = {}
- for (var i = 0; i < services.length; i++) {
- var uuid = services[i].uuid.toUpperCase()
- if (uuid.indexOf(SERVICE.DEVICE_INFO) !== -1) {
- serviceMap.deviceInfo = services[i].uuid
- } else if (uuid.indexOf(SERVICE.DATA_COMM) !== -1) {
- serviceMap.dataComm = services[i].uuid
- }
- }
-
- var tasks = []
- if (serviceMap.deviceInfo) {
- tasks.push(discoverChars(deviceId, serviceMap.deviceInfo, 'deviceInfo'))
- }
- if (serviceMap.dataComm) {
- tasks.push(discoverChars(deviceId, serviceMap.dataComm, 'dataComm'))
- }
-
- Promise.all(tasks).then(function () {
- var serviceId = serviceMap.dataComm || null
- return subscribeToNotifications(deviceId, serviceId)
- }).then(function () {
- _reconnecting = false
- emit('reconnected', { deviceId: deviceId })
- })
- },
- fail: function () {
- // Service discovery failed, treat as reconnect failure
- if (retriesLeft > 1) {
- setTimeout(function () {
- attemptReconnect(deviceId, retriesLeft - 1)
- }, 2000)
- } else {
- _reconnecting = false
- emit('reconnect_failed', { deviceId: deviceId })
- }
- }
- })
- },
- fail: function () {
- if (retriesLeft > 1) {
- setTimeout(function () {
- attemptReconnect(deviceId, retriesLeft - 1)
- }, 2000)
- } else {
- _reconnecting = false
- emit('reconnect_failed', { deviceId: deviceId })
- }
- }
- })
-}
-
-function setAutoReconnect(enabled) {
- _autoReconnect = enabled
-}
-
-wx.onBLEConnectionStateChange(function (res) {
- if (!res.connected) {
- _connected = false
- emit('disconnected', { deviceId: res.deviceId })
- if (_autoReconnect && !_reconnecting && _deviceId) {
- _reconnecting = true
- setTimeout(function () {
- attemptReconnect(_deviceId, 3)
- }, 2000)
- }
- }
-})
-
-function getRegionName(mask) {
- var names = []
- var bits = [
- [0x01, '左脸颊'], [0x02, '右脸颊'], [0x04, '额头'],
- [0x08, '下巴'], [0x10, '鼻部'], [0x20, '左眼周'], [0x40, '右眼周']
- ]
- for (var i = 0; i < bits.length; i++) {
- if (mask & bits[i][0]) names.push(bits[i][1])
- }
- return names
-}
-
-function getWavelengthName(code) {
- var map = { 1: '红外 850nm', 2: '红光 630nm', 3: '紫光 405nm', 4: '黄光 590nm' }
- return map[code] || '未知'
-}
-
-function getModeStateName(code) {
- var map = {
- 0x00: '空闲', 0x01: '扫描中', 0x02: '护理中',
- 0x03: '已暂停', 0x04: '已完成', 0x05: '异常', 0x06: 'OTA升级中'
- }
- return map[code] || '未知'
-}
-
-module.exports = {
- SERVICE: SERVICE,
- CHAR: CHAR,
- CMD: CMD,
- NOTIFY: NOTIFY,
- MODE_STATE: MODE_STATE,
- WAVELENGTH: WAVELENGTH,
- TREAT_MODE: TREAT_MODE,
- REGION: REGION,
- DEVICE_ERR: DEVICE_ERR,
-
- isConnected: isConnected,
- getDeviceId: getDeviceId,
- startScan: startScan,
- stopScan: stopScan,
- connect: connect,
- disconnect: disconnect,
- setAutoReconnect: setAutoReconnect,
- on: on,
- off: off,
-
- readDeviceInfo: readDeviceInfo,
- setParams: setParams,
- startTreatment: startTreatment,
- stopTreatment: stopTreatment,
- queryStatus: queryStatus,
- bindDevice: bindDevice,
- unbindDevice: unbindDevice,
-
- buildFrame: buildFrame,
- parseFrame: parseFrame,
- parseStatusReport: parseStatusReport,
- parseAck: parseAck,
- parseTreatmentComplete: parseTreatmentComplete,
- parseException: parseException,
-
- getRegionName: getRegionName,
- getWavelengthName: getWavelengthName,
- getModeStateName: getModeStateName,
-
- bufferToBytes: bufferToBytes,
- bytesToBuffer: bytesToBuffer,
- hexToBytes: hexToBytes,
- bytesToHex: bytesToHex
-}
diff --git a/miniprogram/services/ble/commands.js b/miniprogram/services/ble/commands.js
new file mode 100644
index 0000000..b20dff6
--- /dev/null
+++ b/miniprogram/services/ble/commands.js
@@ -0,0 +1,187 @@
+// BLE commands: writing commands and high-level device operations
+
+var protocol = require('./protocol')
+var connection = require('./connection')
+
+var _cmdSeq = 0
+var _pendingAcks = {}
+
+function nextSeq() {
+ _cmdSeq = (_cmdSeq + 1) % 256
+ return _cmdSeq
+}
+
+function getPendingAcks() {
+ return _pendingAcks
+}
+
+function clearPendingAcks() {
+ _pendingAcks = {}
+}
+
+// --- low-level write ---
+
+function writeCommand(type, payload) {
+ return new Promise(function (resolve, reject) {
+ if (!connection.isConnected()) {
+ reject({ error_code: 0x0C, error_msg: 'ERR_BLE_DISCONNECTED' })
+ return
+ }
+ var chars = connection.getChars()
+ if (!chars.command) {
+ reject({ error_code: 0xFF, error_msg: 'command characteristic not found' })
+ return
+ }
+
+ var seq = nextSeq()
+ var payloadWithSeq = (payload || []).concat([seq])
+ var buffer = protocol.buildFrame(type, payloadWithSeq)
+
+ _pendingAcks[seq] = { resolve: resolve, reject: reject }
+
+ setTimeout(function () {
+ if (_pendingAcks[seq]) {
+ _pendingAcks[seq].reject({ error_code: 0xFF, error_msg: 'ACK timeout' })
+ delete _pendingAcks[seq]
+ }
+ }, 5000)
+
+ var deviceId = connection.getDeviceId()
+ wx.writeBLECharacteristicValue({
+ deviceId: deviceId,
+ serviceId: chars.command.serviceId,
+ characteristicId: chars.command.uuid,
+ value: buffer,
+ success: function () {},
+ fail: function () {
+ delete _pendingAcks[seq]
+ reject({ error_code: 0x0C, error_msg: 'ERR_BLE_DISCONNECTED' })
+ }
+ })
+ })
+}
+
+function writeCommandWithRetry(type, payload, maxRetries) {
+ if (maxRetries === undefined) maxRetries = 3
+ var attempt = 0
+ function tryOnce() {
+ attempt++
+ return writeCommand(type, payload).catch(function (err) {
+ if (err && err.error_code === 0x0C) {
+ return Promise.reject(err)
+ }
+ if (attempt >= maxRetries) {
+ return Promise.reject(err)
+ }
+ return new Promise(function (resolve) {
+ setTimeout(resolve, 500)
+ }).then(function () {
+ return tryOnce()
+ })
+ })
+ }
+ return tryOnce()
+}
+
+// --- high-level commands ---
+
+function readDeviceInfo() {
+ return new Promise(function (resolve, reject) {
+ var chars = connection.getChars()
+ var deviceId = connection.getDeviceId()
+ if (!connection.isConnected() || !chars.deviceInfo) {
+ reject({ msg: '设备未连接或特征值未就绪' })
+ return
+ }
+ wx.readBLECharacteristicValue({
+ deviceId: deviceId,
+ serviceId: chars.deviceInfo.serviceId,
+ characteristicId: chars.deviceInfo.uuid,
+ success: function () {},
+ fail: function () { reject({ msg: '读取设备信息失败' }) }
+ })
+
+ var handler = function (res) {
+ if (res.characteristicId.toUpperCase().indexOf(protocol.CHAR.DEVICE_INFO) !== -1) {
+ wx.offBLECharacteristicValueChange(handler)
+ var bytes = protocol.bufferToBytes(res.value)
+ if (bytes.length >= 14) {
+ resolve({
+ hw_version: (bytes[0] << 8) | bytes[1],
+ fw_version: (bytes[2] << 8) | bytes[3],
+ device_type: (bytes[4] << 8) | bytes[5],
+ device_id: protocol.bytesToHex(bytes.slice(6, 14))
+ })
+ } else {
+ reject({ msg: '设备信息格式错误' })
+ }
+ }
+ }
+ wx.onBLECharacteristicValueChange(handler)
+ })
+}
+
+function setParams(options) {
+ var regionMask = options.region_mask || protocol.REGION.FULL_FACE
+ var wavelength = options.wavelength || protocol.WAVELENGTH.R
+ var brightness = options.brightness || 200
+ var durationMs = options.duration_ms || 600000
+ var mode = options.mode !== undefined ? options.mode : protocol.TREAT_MODE.NORMAL
+
+ var payload = [
+ regionMask,
+ wavelength,
+ brightness,
+ protocol.uint32ToBytes(durationMs),
+ mode
+ ].reduce(function (a, b) {
+ return a.concat(Array.isArray(b) ? b : [b])
+ }, [])
+
+ return writeCommandWithRetry(protocol.CMD.SET_PARAMS, payload)
+}
+
+function startTreatment(regionMask) {
+ var mask = regionMask || protocol.REGION.FULL_FACE
+ return writeCommandWithRetry(protocol.CMD.START, [mask])
+}
+
+function stopTreatment() {
+ return writeCommandWithRetry(protocol.CMD.STOP, [])
+}
+
+function queryStatus() {
+ return writeCommandWithRetry(protocol.CMD.QUERY_STATUS, [])
+}
+
+function bindDevice(userId, bindToken) {
+ var userBytes = protocol.hexToBytes(userId)
+ var tokenBytes = protocol.hexToBytes(bindToken)
+ var ts = Math.floor(Date.now() / 1000)
+ var tsBytes = protocol.uint32ToBytes(ts)
+
+ var payload = [0x01].concat(userBytes).concat(tokenBytes).concat(tsBytes)
+ return writeCommandWithRetry(protocol.CMD.BIND, payload)
+}
+
+function unbindDevice(userId) {
+ var userBytes = protocol.hexToBytes(userId)
+ var payload = [0x02].concat(userBytes)
+ return writeCommandWithRetry(protocol.CMD.UNBIND, payload)
+}
+
+module.exports = {
+ getPendingAcks: getPendingAcks,
+ clearPendingAcks: clearPendingAcks,
+
+ writeCommand: writeCommand,
+ writeCommandWithRetry: writeCommandWithRetry,
+
+ readDeviceInfo: readDeviceInfo,
+ setParams: setParams,
+ startTreatment: startTreatment,
+ stopTreatment: stopTreatment,
+ queryStatus: queryStatus,
+ bindDevice: bindDevice,
+ unbindDevice: unbindDevice
+}
diff --git a/miniprogram/services/ble/connection.js b/miniprogram/services/ble/connection.js
new file mode 100644
index 0000000..478a8df
--- /dev/null
+++ b/miniprogram/services/ble/connection.js
@@ -0,0 +1,341 @@
+// BLE connection: scan, connect, disconnect, reconnect logic
+
+var protocol = require('./protocol')
+var SERVICE = protocol.SERVICE
+var CHAR = protocol.CHAR
+
+// Shared state — accessed by commands.js via getters/setters
+var _deviceId = null
+var _connected = false
+var _chars = {}
+var _autoReconnect = true
+var _reconnecting = false
+
+// Event emitter — shared across modules
+var _listeners = {}
+
+function on(event, callback) {
+ if (!_listeners[event]) _listeners[event] = []
+ _listeners[event].push(callback)
+}
+
+function off(event, callback) {
+ if (!_listeners[event]) return
+ if (callback) {
+ _listeners[event] = _listeners[event].filter(function (cb) { return cb !== callback })
+ } else {
+ _listeners[event] = []
+ }
+}
+
+function emit(event, data) {
+ if (!_listeners[event]) return
+ _listeners[event].forEach(function (cb) {
+ try { cb(data) } catch (e) { console.error('ble emit error:', e) }
+ })
+}
+
+// --- state accessors (used by commands.js) ---
+
+function getDeviceId() {
+ return _deviceId
+}
+
+function isConnected() {
+ return _connected && _deviceId !== null
+}
+
+function getChars() {
+ return _chars
+}
+
+// --- notification handling ---
+
+function handleNotification(frame) {
+ var pendingAcks = require('./commands').getPendingAcks()
+ switch (frame.type) {
+ case protocol.NOTIFY.STATUS_REPORT:
+ emit('status', protocol.parseStatusReport(frame.payload))
+ break
+ case protocol.NOTIFY.ACK:
+ var ack = protocol.parseAck(frame.payload)
+ emit('ack', ack)
+ if (pendingAcks[ack.seq]) {
+ if (ack.error_code === 0) {
+ pendingAcks[ack.seq].resolve(ack)
+ } else {
+ pendingAcks[ack.seq].reject(ack)
+ }
+ delete pendingAcks[ack.seq]
+ }
+ break
+ case protocol.NOTIFY.TREATMENT_COMPLETE:
+ emit('treatment_complete', protocol.parseTreatmentComplete(frame.payload))
+ break
+ case protocol.NOTIFY.EXCEPTION:
+ emit('exception', protocol.parseException(frame.payload))
+ break
+ case protocol.NOTIFY.BIND_SUCCESS:
+ emit('bind_result', { success: frame.payload[0] === 0x00 })
+ break
+ }
+}
+
+// --- service/characteristic discovery ---
+
+function discoverChars(deviceId, serviceId, group) {
+ return new Promise(function (resolve) {
+ wx.getBLEDeviceCharacteristics({
+ deviceId: deviceId,
+ serviceId: serviceId,
+ success: function (res) {
+ var chars = res.characteristics || []
+ for (var i = 0; i < chars.length; i++) {
+ var c = chars[i]
+ var uuid = c.uuid.toUpperCase()
+ if (uuid.indexOf(CHAR.DEVICE_INFO) !== -1) _chars.deviceInfo = { uuid: c.uuid, serviceId: serviceId }
+ if (uuid.indexOf(CHAR.COMMAND) !== -1) _chars.command = { uuid: c.uuid, serviceId: serviceId }
+ if (uuid.indexOf(CHAR.STATUS) !== -1) _chars.status = { uuid: c.uuid, serviceId: serviceId }
+ if (uuid.indexOf(CHAR.BOND_INFO) !== -1) _chars.bondInfo = { uuid: c.uuid, serviceId: serviceId }
+ if (uuid.indexOf(CHAR.OTA_CONTROL) !== -1) _chars.otaControl = { uuid: c.uuid, serviceId: serviceId }
+ if (uuid.indexOf(CHAR.OTA_DATA) !== -1) _chars.otaData = { uuid: c.uuid, serviceId: serviceId }
+ if (uuid.indexOf(CHAR.OTA_STATUS) !== -1) _chars.otaStatus = { uuid: c.uuid, serviceId: serviceId }
+ }
+ resolve()
+ },
+ fail: function () { resolve() }
+ })
+ })
+}
+
+function subscribeToNotifications(deviceId, serviceId) {
+ return new Promise(function (resolve) {
+ if (!_chars.status) { resolve(); return }
+
+ wx.notifyBLECharacteristicValueChange({
+ deviceId: deviceId,
+ serviceId: _chars.status.serviceId,
+ characteristicId: _chars.status.uuid,
+ state: true,
+ success: function () {
+ wx.onBLECharacteristicValueChange(function (res) {
+ var frame = protocol.parseFrame(res.value)
+ if (frame) handleNotification(frame)
+ })
+ resolve()
+ },
+ fail: function () { resolve() }
+ })
+ })
+}
+
+function discoverServices(deviceId, callbacks) {
+ wx.getBLEDeviceServices({
+ deviceId: deviceId,
+ success: function (res) {
+ var services = res.services
+ var serviceMap = {}
+ for (var i = 0; i < services.length; i++) {
+ var uuid = services[i].uuid.toUpperCase()
+ if (uuid.indexOf(SERVICE.DEVICE_INFO) !== -1) {
+ serviceMap.deviceInfo = services[i].uuid
+ } else if (uuid.indexOf(SERVICE.DATA_COMM) !== -1) {
+ serviceMap.dataComm = services[i].uuid
+ } else if (uuid.indexOf(SERVICE.OTA) !== -1) {
+ serviceMap.ota = services[i].uuid
+ }
+ }
+
+ var tasks = []
+ if (serviceMap.deviceInfo) {
+ tasks.push(discoverChars(deviceId, serviceMap.deviceInfo, 'deviceInfo'))
+ }
+ if (serviceMap.dataComm) {
+ tasks.push(discoverChars(deviceId, serviceMap.dataComm, 'dataComm'))
+ }
+
+ Promise.all(tasks).then(function () {
+ subscribeToNotifications(deviceId, serviceMap.dataComm).then(function () {
+ if (callbacks.onConnected) callbacks.onConnected({ deviceId: deviceId })
+ })
+ })
+ },
+ fail: function () {
+ if (callbacks.onError) callbacks.onError({ msg: '服务发现失败' })
+ }
+ })
+}
+
+// --- scan & connect ---
+
+function startScan(callbacks) {
+ wx.openBluetoothAdapter({
+ success: function () {
+ wx.startBluetoothDevicesDiscovery({
+ allowDuplicatesKey: false,
+ success: function () {
+ wx.offBluetoothDeviceFound()
+ wx.onBluetoothDeviceFound(function (res) {
+ var devices = res.devices || []
+ for (var i = 0; i < devices.length; i++) {
+ var d = devices[i]
+ var name = (d.name || '').toUpperCase()
+ var localName = (d.localName || '').toUpperCase()
+ if (name.indexOf('HOX') !== -1 || localName.indexOf('HOX') !== -1 ||
+ name.indexOf('LIGHTMASK') !== -1 || localName.indexOf('LIGHTMASK') !== -1) {
+ wx.stopBluetoothDevicesDiscovery({})
+ if (callbacks.onFound) callbacks.onFound(d)
+ connect(d.deviceId, callbacks)
+ return
+ }
+ }
+ })
+ },
+ fail: function () {
+ if (callbacks.onError) callbacks.onError({ msg: '扫描失败' })
+ }
+ })
+ },
+ fail: function () {
+ if (callbacks.onError) callbacks.onError({ msg: '请开启蓝牙' })
+ }
+ })
+}
+
+function stopScan() {
+ wx.stopBluetoothDevicesDiscovery({})
+ wx.offBluetoothDeviceFound()
+}
+
+function connect(deviceId, callbacks) {
+ _deviceId = deviceId
+ _chars = {}
+
+ wx.createBLEConnection({
+ deviceId: deviceId,
+ timeout: 10000,
+ success: function () {
+ _connected = true
+ discoverServices(deviceId, callbacks)
+ },
+ fail: function () {
+ _connected = false
+ if (callbacks.onError) callbacks.onError({ msg: '连接失败' })
+ }
+ })
+}
+
+function disconnect() {
+ _autoReconnect = false
+ _reconnecting = false
+ if (_deviceId) {
+ wx.closeBLEConnection({ deviceId: _deviceId })
+ _deviceId = null
+ }
+ _connected = false
+ _chars = {}
+ var commands = require('./commands')
+ commands.clearPendingAcks()
+ _listeners = {}
+ wx.closeBluetoothAdapter({})
+}
+
+// --- reconnect ---
+
+function attemptReconnect(deviceId, retriesLeft) {
+ wx.createBLEConnection({
+ deviceId: deviceId,
+ timeout: 10000,
+ success: function () {
+ _connected = true
+ _chars = {}
+ wx.getBLEDeviceServices({
+ deviceId: deviceId,
+ success: function (res) {
+ var services = res.services
+ var serviceMap = {}
+ for (var i = 0; i < services.length; i++) {
+ var uuid = services[i].uuid.toUpperCase()
+ if (uuid.indexOf(SERVICE.DEVICE_INFO) !== -1) {
+ serviceMap.deviceInfo = services[i].uuid
+ } else if (uuid.indexOf(SERVICE.DATA_COMM) !== -1) {
+ serviceMap.dataComm = services[i].uuid
+ }
+ }
+
+ var tasks = []
+ if (serviceMap.deviceInfo) {
+ tasks.push(discoverChars(deviceId, serviceMap.deviceInfo, 'deviceInfo'))
+ }
+ if (serviceMap.dataComm) {
+ tasks.push(discoverChars(deviceId, serviceMap.dataComm, 'dataComm'))
+ }
+
+ Promise.all(tasks).then(function () {
+ var serviceId = serviceMap.dataComm || null
+ return subscribeToNotifications(deviceId, serviceId)
+ }).then(function () {
+ _reconnecting = false
+ emit('reconnected', { deviceId: deviceId })
+ })
+ },
+ fail: function () {
+ if (retriesLeft > 1) {
+ setTimeout(function () {
+ attemptReconnect(deviceId, retriesLeft - 1)
+ }, 2000)
+ } else {
+ _reconnecting = false
+ emit('reconnect_failed', { deviceId: deviceId })
+ }
+ }
+ })
+ },
+ fail: function () {
+ if (retriesLeft > 1) {
+ setTimeout(function () {
+ attemptReconnect(deviceId, retriesLeft - 1)
+ }, 2000)
+ } else {
+ _reconnecting = false
+ emit('reconnect_failed', { deviceId: deviceId })
+ }
+ }
+ })
+}
+
+function setAutoReconnect(enabled) {
+ _autoReconnect = enabled
+}
+
+// --- BLE connection state change listener ---
+
+wx.onBLEConnectionStateChange(function (res) {
+ if (!res.connected) {
+ _connected = false
+ emit('disconnected', { deviceId: res.deviceId })
+ if (_autoReconnect && !_reconnecting && _deviceId) {
+ _reconnecting = true
+ setTimeout(function () {
+ attemptReconnect(_deviceId, 3)
+ }, 2000)
+ }
+ }
+})
+
+module.exports = {
+ on: on,
+ off: off,
+ emit: emit,
+
+ getDeviceId: getDeviceId,
+ isConnected: isConnected,
+ getChars: getChars,
+
+ startScan: startScan,
+ stopScan: stopScan,
+ connect: connect,
+ disconnect: disconnect,
+ attemptReconnect: attemptReconnect,
+ setAutoReconnect: setAutoReconnect
+}
diff --git a/miniprogram/services/ble/index.js b/miniprogram/services/ble/index.js
new file mode 100644
index 0000000..56501a9
--- /dev/null
+++ b/miniprogram/services/ble/index.js
@@ -0,0 +1,59 @@
+// BLE service barrel — re-exports a unified API
+// Usage: var ble = require('../../services/ble')
+
+var protocol = require('./protocol')
+var connection = require('./connection')
+var commands = require('./commands')
+
+module.exports = {
+ // Constants
+ SERVICE: protocol.SERVICE,
+ CHAR: protocol.CHAR,
+ CMD: protocol.CMD,
+ NOTIFY: protocol.NOTIFY,
+ MODE_STATE: protocol.MODE_STATE,
+ WAVELENGTH: protocol.WAVELENGTH,
+ TREAT_MODE: protocol.TREAT_MODE,
+ REGION: protocol.REGION,
+ DEVICE_ERR: protocol.DEVICE_ERR,
+
+ // Connection
+ isConnected: connection.isConnected,
+ getDeviceId: connection.getDeviceId,
+ startScan: connection.startScan,
+ stopScan: connection.stopScan,
+ connect: connection.connect,
+ disconnect: connection.disconnect,
+ setAutoReconnect: connection.setAutoReconnect,
+ on: connection.on,
+ off: connection.off,
+
+ // Commands
+ readDeviceInfo: commands.readDeviceInfo,
+ setParams: commands.setParams,
+ startTreatment: commands.startTreatment,
+ stopTreatment: commands.stopTreatment,
+ queryStatus: commands.queryStatus,
+ bindDevice: commands.bindDevice,
+ unbindDevice: commands.unbindDevice,
+
+ // Protocol utilities
+ buildFrame: protocol.buildFrame,
+ parseFrame: protocol.parseFrame,
+ parseStatusReport: protocol.parseStatusReport,
+ parseAck: protocol.parseAck,
+ parseTreatmentComplete: protocol.parseTreatmentComplete,
+ parseException: protocol.parseException,
+
+ // Display helpers
+ REGION_NAMES: protocol.REGION_NAMES,
+ getRegionName: protocol.getRegionName,
+ getWavelengthName: protocol.getWavelengthName,
+ getModeStateName: protocol.getModeStateName,
+
+ // Byte utilities
+ bufferToBytes: protocol.bufferToBytes,
+ bytesToBuffer: protocol.bytesToBuffer,
+ hexToBytes: protocol.hexToBytes,
+ bytesToHex: protocol.bytesToHex
+}
diff --git a/miniprogram/services/ble/protocol.js b/miniprogram/services/ble/protocol.js
new file mode 100644
index 0000000..7a2ef8e
--- /dev/null
+++ b/miniprogram/services/ble/protocol.js
@@ -0,0 +1,272 @@
+// BLE protocol: frame encoding/decoding, checksum, constants
+
+var SERVICE = {
+ DEVICE_INFO: 'FFE0',
+ DATA_COMM: 'FFE1',
+ OTA: 'FFE2'
+}
+
+var CHAR = {
+ DEVICE_INFO: 'FFE3',
+ COMMAND: 'FFE4',
+ STATUS: 'FFE5',
+ BOND_INFO: 'FFE6',
+ OTA_CONTROL: 'FFE7',
+ OTA_DATA: 'FFE8',
+ OTA_STATUS: 'FFE9'
+}
+
+var CMD = {
+ SET_PARAMS: 0x01,
+ START: 0x02,
+ STOP: 0x03,
+ QUERY_STATUS: 0x04,
+ BIND: 0x05,
+ UNBIND: 0x06
+}
+
+var NOTIFY = {
+ STATUS_REPORT: 0x21,
+ ACK: 0x22,
+ TREATMENT_COMPLETE: 0x31,
+ EXCEPTION: 0x32,
+ BIND_SUCCESS: 0x33
+}
+
+var MODE_STATE = {
+ IDLE: 0x00,
+ SCANNING: 0x01,
+ ACTIVE: 0x02,
+ PAUSED: 0x03,
+ COMPLETED: 0x04,
+ ERROR: 0x05,
+ OTA: 0x06
+}
+
+var WAVELENGTH = {
+ IR: 1,
+ R: 2,
+ UV: 3,
+ Y: 4
+}
+
+var TREAT_MODE = {
+ NORMAL: 0,
+ SMART: 1
+}
+
+var REGION = {
+ LEFT_CHEEK: 0x01,
+ RIGHT_CHEEK: 0x02,
+ FOREHEAD: 0x04,
+ CHIN: 0x08,
+ NOSE: 0x10,
+ LEFT_EYE: 0x20,
+ RIGHT_EYE: 0x40,
+ FULL_FACE: 0x7F
+}
+
+var REGION_NAMES = ['left_cheek', 'right_cheek', 'forehead', 'chin', 'nose', 'left_eye', 'right_eye']
+
+var DEVICE_ERR = {
+ 0x00: 'SUCCESS',
+ 0x01: 'ERR_REGION_INVALID',
+ 0x02: 'ERR_REGION_EMPTY',
+ 0x03: 'ERR_BRIGHTNESS_INVALID',
+ 0x04: 'ERR_DURATION_INVALID',
+ 0x05: 'ERR_NOT_BOUND',
+ 0x06: 'ERR_NO_SUBSCRIPTION',
+ 0x07: 'ERR_TEMP_HIGH',
+ 0x08: 'ERR_BATTERY_LOW',
+ 0x09: 'ERR_ALREADY_RUNNING',
+ 0x0A: 'ERR_NOT_RUNNING',
+ 0x0B: 'ERR_OTA_FAILED',
+ 0x0C: 'ERR_BLE_DISCONNECTED'
+}
+
+// --- byte utilities ---
+
+function bufferToBytes(buffer) {
+ var arr = new Uint8Array(buffer)
+ var bytes = []
+ for (var i = 0; i < arr.length; i++) {
+ bytes.push(arr[i])
+ }
+ return bytes
+}
+
+function bytesToBuffer(bytes) {
+ var buffer = new ArrayBuffer(bytes.length)
+ var view = new Uint8Array(buffer)
+ for (var i = 0; i < bytes.length; i++) {
+ view[i] = bytes[i]
+ }
+ return buffer
+}
+
+function xorChecksum(bytes) {
+ var result = 0
+ for (var i = 0; i < bytes.length; i++) {
+ result ^= bytes[i]
+ }
+ return result
+}
+
+function uint32ToBytes(value) {
+ return [
+ (value >> 24) & 0xFF,
+ (value >> 16) & 0xFF,
+ (value >> 8) & 0xFF,
+ value & 0xFF
+ ]
+}
+
+function bytesToUint32(bytes, offset) {
+ return (bytes[offset] << 24) | (bytes[offset + 1] << 16) | (bytes[offset + 2] << 8) | bytes[offset + 3]
+}
+
+function hexToBytes(hex) {
+ var bytes = []
+ for (var i = 0; i < hex.length; i += 2) {
+ bytes.push(parseInt(hex.substr(i, 2), 16))
+ }
+ return bytes
+}
+
+function bytesToHex(bytes) {
+ var hex = ''
+ for (var i = 0; i < bytes.length; i++) {
+ hex += ('0' + bytes[i].toString(16)).slice(-2)
+ }
+ return hex.toUpperCase()
+}
+
+// --- frame encoding/decoding ---
+
+function buildFrame(type, payload) {
+ var len = payload ? payload.length : 0
+ var frame = [0xAA, 0x55, len, type]
+ if (payload && payload.length > 0) {
+ frame = frame.concat(payload)
+ }
+ var checkBytes = frame.slice(0)
+ frame.push(xorChecksum(checkBytes))
+ return bytesToBuffer(frame)
+}
+
+function parseFrame(buffer) {
+ var bytes = bufferToBytes(buffer)
+ if (bytes.length < 5) return null
+ if (bytes[0] !== 0xAA || bytes[1] !== 0x55) return null
+ var len = bytes[2]
+ if (bytes.length < 5 + len) return null
+ var type = bytes[3]
+ var payload = bytes.slice(4, 4 + len)
+ var checksum = bytes[4 + len]
+ var expected = xorChecksum(bytes.slice(0, 4 + len))
+ if (checksum !== expected) return null
+ return { type: type, payload: payload, seq: payload.length > 0 ? payload[payload.length - 1] : 0 }
+}
+
+// --- notification parsing ---
+
+function parseStatusReport(payload) {
+ if (payload.length < 14) return null
+ return {
+ mode_state: payload[0],
+ region_mask: payload[1],
+ wavelength: payload[2],
+ brightness: payload[3],
+ remaining_ms: bytesToUint32(payload, 4),
+ error_code: payload[8],
+ command_seq: payload[9],
+ battery: payload[10],
+ temperature: payload[11],
+ bind_status: payload[12],
+ subscription: payload[13]
+ }
+}
+
+function parseAck(payload) {
+ return {
+ seq: payload[0],
+ error_code: payload.length > 1 ? payload[1] : 0,
+ error_msg: DEVICE_ERR[payload.length > 1 ? payload[1] : 0] || 'UNKNOWN'
+ }
+}
+
+function parseTreatmentComplete(payload) {
+ return {
+ session_id: bytesToHex(payload.slice(0, 8)),
+ regions: payload[8],
+ total_duration_ms: bytesToUint32(payload, 9),
+ avg_pd: payload[13]
+ }
+}
+
+function parseException(payload) {
+ return {
+ error_code: payload[0],
+ error_msg: DEVICE_ERR[payload[0]] || 'UNKNOWN',
+ temperature: payload.length > 1 ? payload[1] : 0
+ }
+}
+
+// --- display helpers ---
+
+function getRegionName(mask) {
+ var names = []
+ var bits = [
+ [0x01, '左脸颊'], [0x02, '右脸颊'], [0x04, '额头'],
+ [0x08, '下巴'], [0x10, '鼻部'], [0x20, '左眼周'], [0x40, '右眼周']
+ ]
+ for (var i = 0; i < bits.length; i++) {
+ if (mask & bits[i][0]) names.push(bits[i][1])
+ }
+ return names
+}
+
+function getWavelengthName(code) {
+ var map = { 1: '红外 850nm', 2: '红光 630nm', 3: '紫光 405nm', 4: '黄光 590nm' }
+ return map[code] || '未知'
+}
+
+function getModeStateName(code) {
+ var map = {
+ 0x00: '空闲', 0x01: '扫描中', 0x02: '护理中',
+ 0x03: '已暂停', 0x04: '已完成', 0x05: '异常', 0x06: 'OTA升级中'
+ }
+ return map[code] || '未知'
+}
+
+module.exports = {
+ SERVICE: SERVICE,
+ CHAR: CHAR,
+ CMD: CMD,
+ NOTIFY: NOTIFY,
+ MODE_STATE: MODE_STATE,
+ WAVELENGTH: WAVELENGTH,
+ TREAT_MODE: TREAT_MODE,
+ REGION: REGION,
+ REGION_NAMES: REGION_NAMES,
+ DEVICE_ERR: DEVICE_ERR,
+
+ bufferToBytes: bufferToBytes,
+ bytesToBuffer: bytesToBuffer,
+ xorChecksum: xorChecksum,
+ uint32ToBytes: uint32ToBytes,
+ bytesToUint32: bytesToUint32,
+ hexToBytes: hexToBytes,
+ bytesToHex: bytesToHex,
+
+ buildFrame: buildFrame,
+ parseFrame: parseFrame,
+ parseStatusReport: parseStatusReport,
+ parseAck: parseAck,
+ parseTreatmentComplete: parseTreatmentComplete,
+ parseException: parseException,
+
+ getRegionName: getRegionName,
+ getWavelengthName: getWavelengthName,
+ getModeStateName: getModeStateName
+}
diff --git a/miniprogram/utils/api.js b/miniprogram/utils/api.js
new file mode 100644
index 0000000..c3fcc35
--- /dev/null
+++ b/miniprogram/utils/api.js
@@ -0,0 +1,32 @@
+var http = require('./request')
+
+module.exports = {
+ // Auth
+ login: function (code) { return http.post('/api/v1/auth/login', { code: code }) },
+ getPhone: function (code) { return http.post('/api/v1/user/phone', { code: code }) },
+
+ // User
+ getProfile: function () { return http.get('/api/v1/user/profile') },
+ updateProfile: function (data) { return http.put('/api/v1/user/profile', data) },
+
+ // Device
+ getDevices: function () { return http.get('/api/v1/device/list') },
+ bindDevice: function (deviceId) { return http.post('/api/v1/device/bind', { device_id: deviceId }) },
+ confirmBind: function (deviceId, token) { return http.post('/api/v1/device/bind/confirm', { device_id: deviceId, bind_token: token }) },
+ mockBind: function (deviceId) { return http.post('/api/v1/device/mock-bind', { device_id: deviceId }) },
+ unbindDevice: function (deviceId) { return http.post('/api/v1/device/unbind', { device_id: deviceId }) },
+
+ // Subscription
+ getSubscription: function () { return http.get('/api/v1/subscription') },
+ activateTrial: function () { return http.post('/api/v1/subscription/trial') },
+ purchase: function (plan) { return http.post('/api/v1/subscription/purchase', { plan: plan }) },
+
+ // Treatment
+ getRecords: function (params) { return http.get('/api/v1/treatment/history', params) },
+ syncTreatment: function (data) { return http.post('/api/v1/treatment/sync', data) },
+ getRecordDetail: function (id) { return http.get('/api/v1/treatment/' + id) },
+
+ // Device commands
+ getPendingCommands: function (deviceId) { return http.get('/api/v1/device/command/pending', { device_id: deviceId }) },
+ reportCommandResult: function (data) { return http.post('/api/v1/device/command/result', data) }
+}
diff --git a/miniprogram/utils/page.js b/miniprogram/utils/page.js
new file mode 100644
index 0000000..b02ff6b
--- /dev/null
+++ b/miniprogram/utils/page.js
@@ -0,0 +1,25 @@
+var app = getApp()
+
+/** Get status bar height for custom navigation pages */
+function getStatusBarHeight() {
+ return app.globalData.statusBarHeight || 44
+}
+
+/** Standard back navigation with fallback */
+function navigateBack(fallbackUrl) {
+ wx.navigateBack({
+ fail: function () {
+ if (fallbackUrl) {
+ wx.reLaunch({ url: fallbackUrl })
+ }
+ }
+ })
+}
+
+/** Check if dev mode is enabled */
+function isDevMode() {
+ var config = require('../config/env')
+ return config.__DEV__ || false
+}
+
+module.exports = { getStatusBarHeight: getStatusBarHeight, navigateBack: navigateBack, isDevMode: isDevMode }
diff --git a/server/package-lock.json b/server/package-lock.json
index 7e7db4e..aca821f 100644
--- a/server/package-lock.json
+++ b/server/package-lock.json
@@ -11,6 +11,7 @@
"bcryptjs": "^2.4.3",
"cos-nodejs-sdk-v5": "^2.14.7",
"dotenv": "^16.4.5",
+ "express": "^5.2.1",
"jsonwebtoken": "^9.0.2",
"mysql2": "^3.11.3"
},
@@ -26,6 +27,44 @@
"undici-types": "~7.19.0"
}
},
+ "node_modules/accepts": {
+ "version": "2.0.0",
+ "resolved": "https://registry.npmmirror.com/accepts/-/accepts-2.0.0.tgz",
+ "integrity": "sha512-5cvg6CtKwfgdmVqY1WIiXKc3Q1bkRqGLi+2W/6ao+6Y7gu/RCwRuAhGEzh5B4KlszSuTLgZYuqFqo5bImjNKng==",
+ "license": "MIT",
+ "dependencies": {
+ "mime-types": "^3.0.0",
+ "negotiator": "^1.0.0"
+ },
+ "engines": {
+ "node": ">= 0.6"
+ }
+ },
+ "node_modules/accepts/node_modules/mime-db": {
+ "version": "1.54.0",
+ "resolved": "https://registry.npmmirror.com/mime-db/-/mime-db-1.54.0.tgz",
+ "integrity": "sha512-aU5EJuIN2WDemCcAp2vFBfp/m4EAhWJnUNSSw0ixs7/kXbd6Pg64EmwJkNdFhB8aWt1sH2CTXrLxo/iAGV3oPQ==",
+ "license": "MIT",
+ "engines": {
+ "node": ">= 0.6"
+ }
+ },
+ "node_modules/accepts/node_modules/mime-types": {
+ "version": "3.0.2",
+ "resolved": "https://registry.npmmirror.com/mime-types/-/mime-types-3.0.2.tgz",
+ "integrity": "sha512-Lbgzdk0h4juoQ9fCKXW4by0UJqj+nOOrI9MJ1sSj4nI8aI2eo1qmvQEie4VD1glsS250n15LsWsYtCugiStS5A==",
+ "license": "MIT",
+ "dependencies": {
+ "mime-db": "^1.54.0"
+ },
+ "engines": {
+ "node": ">=18"
+ },
+ "funding": {
+ "type": "opencollective",
+ "url": "https://opencollective.com/express"
+ }
+ },
"node_modules/ajv": {
"version": "7.2.4",
"resolved": "https://registry.npmmirror.com/ajv/-/ajv-7.2.4.tgz",
@@ -131,12 +170,89 @@
"integrity": "sha512-V/Hy/X9Vt7f3BbPJEi8BdVFMByHi+jNXrYkW3huaybV/kQ0KJg0Y6PkEMbn+zeT+i+SiKZ/HMqJGIIt4LZDqNQ==",
"license": "MIT"
},
+ "node_modules/body-parser": {
+ "version": "2.2.2",
+ "resolved": "https://registry.npmmirror.com/body-parser/-/body-parser-2.2.2.tgz",
+ "integrity": "sha512-oP5VkATKlNwcgvxi0vM0p/D3n2C3EReYVX+DNYs5TjZFn/oQt2j+4sVJtSMr18pdRr8wjTcBl6LoV+FUwzPmNA==",
+ "license": "MIT",
+ "dependencies": {
+ "bytes": "^3.1.2",
+ "content-type": "^1.0.5",
+ "debug": "^4.4.3",
+ "http-errors": "^2.0.0",
+ "iconv-lite": "^0.7.0",
+ "on-finished": "^2.4.1",
+ "qs": "^6.14.1",
+ "raw-body": "^3.0.1",
+ "type-is": "^2.0.1"
+ },
+ "engines": {
+ "node": ">=18"
+ },
+ "funding": {
+ "type": "opencollective",
+ "url": "https://opencollective.com/express"
+ }
+ },
+ "node_modules/body-parser/node_modules/qs": {
+ "version": "6.15.1",
+ "resolved": "https://registry.npmmirror.com/qs/-/qs-6.15.1.tgz",
+ "integrity": "sha512-6YHEFRL9mfgcAvql/XhwTvf5jKcOiiupt2FiJxHkiX1z4j7WL8J/jRHYLluORvc1XxB5rV20KoeK00gVJamspg==",
+ "license": "BSD-3-Clause",
+ "dependencies": {
+ "side-channel": "^1.1.0"
+ },
+ "engines": {
+ "node": ">=0.6"
+ },
+ "funding": {
+ "url": "https://github.com/sponsors/ljharb"
+ }
+ },
"node_modules/buffer-equal-constant-time": {
"version": "1.0.1",
"resolved": "https://registry.npmmirror.com/buffer-equal-constant-time/-/buffer-equal-constant-time-1.0.1.tgz",
"integrity": "sha512-zRpUiDwd/xk6ADqPMATG8vc9VPrkck7T07OIx0gnjmJAnHnTVXNQG3vfvWNuiZIkwu9KrKdA1iJKfsfTVxE6NA==",
"license": "BSD-3-Clause"
},
+ "node_modules/bytes": {
+ "version": "3.1.2",
+ "resolved": "https://registry.npmmirror.com/bytes/-/bytes-3.1.2.tgz",
+ "integrity": "sha512-/Nf7TyzTx6S3yRJObOAV7956r8cr2+Oj8AC5dt8wSP3BQAoeX58NoHyCU8P8zGkNXStjTSi6fzO6F0pBdcYbEg==",
+ "license": "MIT",
+ "engines": {
+ "node": ">= 0.8"
+ }
+ },
+ "node_modules/call-bind-apply-helpers": {
+ "version": "1.0.2",
+ "resolved": "https://registry.npmmirror.com/call-bind-apply-helpers/-/call-bind-apply-helpers-1.0.2.tgz",
+ "integrity": "sha512-Sp1ablJ0ivDkSzjcaJdxEunN5/XvksFJ2sMBFfq6x0ryhQV/2b/KwFe21cMpmHtPOSij8K99/wSfoEuTObmuMQ==",
+ "license": "MIT",
+ "dependencies": {
+ "es-errors": "^1.3.0",
+ "function-bind": "^1.1.2"
+ },
+ "engines": {
+ "node": ">= 0.4"
+ }
+ },
+ "node_modules/call-bound": {
+ "version": "1.0.4",
+ "resolved": "https://registry.npmmirror.com/call-bound/-/call-bound-1.0.4.tgz",
+ "integrity": "sha512-+ys997U96po4Kx/ABpBCqhA9EuxJaQWDQg7295H4hBphv3IZg0boBKuwYpt4YXp6MZ5AmZQnU/tyMTlRpaSejg==",
+ "license": "MIT",
+ "dependencies": {
+ "call-bind-apply-helpers": "^1.0.2",
+ "get-intrinsic": "^1.3.0"
+ },
+ "engines": {
+ "node": ">= 0.4"
+ },
+ "funding": {
+ "url": "https://github.com/sponsors/ljharb"
+ }
+ },
"node_modules/caseless": {
"version": "0.12.0",
"resolved": "https://registry.npmmirror.com/caseless/-/caseless-0.12.0.tgz",
@@ -180,6 +296,46 @@
"url": "https://github.com/sponsors/sindresorhus"
}
},
+ "node_modules/content-disposition": {
+ "version": "1.1.0",
+ "resolved": "https://registry.npmmirror.com/content-disposition/-/content-disposition-1.1.0.tgz",
+ "integrity": "sha512-5jRCH9Z/+DRP7rkvY83B+yGIGX96OYdJmzngqnw2SBSxqCFPd0w2km3s5iawpGX8krnwSGmF0FW5Nhr0Hfai3g==",
+ "license": "MIT",
+ "engines": {
+ "node": ">=18"
+ },
+ "funding": {
+ "type": "opencollective",
+ "url": "https://opencollective.com/express"
+ }
+ },
+ "node_modules/content-type": {
+ "version": "1.0.5",
+ "resolved": "https://registry.npmmirror.com/content-type/-/content-type-1.0.5.tgz",
+ "integrity": "sha512-nTjqfcBFEipKdXCv4YDQWCfmcLZKm81ldF0pAopTvyrFGVbcR6P/VAAd5G7N+0tTr8QqiU0tFadD6FK4NtJwOA==",
+ "license": "MIT",
+ "engines": {
+ "node": ">= 0.6"
+ }
+ },
+ "node_modules/cookie": {
+ "version": "0.7.2",
+ "resolved": "https://registry.npmmirror.com/cookie/-/cookie-0.7.2.tgz",
+ "integrity": "sha512-yki5XnKuf750l50uGTllt6kKILY4nQ1eNIQatoXEByZ5dWgnKqbnqmTrBE5B4N7lrMJKQ2ytWMiTO2o0v6Ew/w==",
+ "license": "MIT",
+ "engines": {
+ "node": ">= 0.6"
+ }
+ },
+ "node_modules/cookie-signature": {
+ "version": "1.2.2",
+ "resolved": "https://registry.npmmirror.com/cookie-signature/-/cookie-signature-1.2.2.tgz",
+ "integrity": "sha512-D76uU73ulSXrD1UXF4KE2TMxVVwhsnCgfAyTg9k8P6KGZjlXKrOLe4dJQKI3Bxi5wjesZoFXJWElNWBjPZMbhg==",
+ "license": "MIT",
+ "engines": {
+ "node": ">=6.6.0"
+ }
+ },
"node_modules/core-util-is": {
"version": "1.0.2",
"resolved": "https://registry.npmmirror.com/core-util-is/-/core-util-is-1.0.2.tgz",
@@ -228,6 +384,23 @@
"url": "https://github.com/sponsors/sindresorhus"
}
},
+ "node_modules/debug": {
+ "version": "4.4.3",
+ "resolved": "https://registry.npmmirror.com/debug/-/debug-4.4.3.tgz",
+ "integrity": "sha512-RGwwWnwQvkVfavKVt22FGLw+xYSdzARwm0ru6DhTVA3umU5hZc28V3kO4stgYryrTlLpuvgI9GiijltAjNbcqA==",
+ "license": "MIT",
+ "dependencies": {
+ "ms": "^2.1.3"
+ },
+ "engines": {
+ "node": ">=6.0"
+ },
+ "peerDependenciesMeta": {
+ "supports-color": {
+ "optional": true
+ }
+ }
+ },
"node_modules/delayed-stream": {
"version": "1.0.0",
"resolved": "https://registry.npmmirror.com/delayed-stream/-/delayed-stream-1.0.0.tgz",
@@ -246,6 +419,15 @@
"node": ">=0.10"
}
},
+ "node_modules/depd": {
+ "version": "2.0.0",
+ "resolved": "https://registry.npmmirror.com/depd/-/depd-2.0.0.tgz",
+ "integrity": "sha512-g7nH6P6dyDioJogAAGprGpCtVImJhpPk/roCzdb3fIh61/s/nPsfR6onyMwkCAR/OlC3yBC0lESvUoQEAssIrw==",
+ "license": "MIT",
+ "engines": {
+ "node": ">= 0.8"
+ }
+ },
"node_modules/dot-prop": {
"version": "6.0.1",
"resolved": "https://registry.npmmirror.com/dot-prop/-/dot-prop-6.0.1.tgz",
@@ -273,6 +455,20 @@
"url": "https://dotenvx.com"
}
},
+ "node_modules/dunder-proto": {
+ "version": "1.0.1",
+ "resolved": "https://registry.npmmirror.com/dunder-proto/-/dunder-proto-1.0.1.tgz",
+ "integrity": "sha512-KIN/nDJBQRcXw0MLVhZE9iQHmG68qAVIBg9CqmUYjmQIhgij9U5MFvrqkUL5FbtyyzZuOeOt0zdeRe4UY7ct+A==",
+ "license": "MIT",
+ "dependencies": {
+ "call-bind-apply-helpers": "^1.0.1",
+ "es-errors": "^1.3.0",
+ "gopd": "^1.2.0"
+ },
+ "engines": {
+ "node": ">= 0.4"
+ }
+ },
"node_modules/ecc-jsbn": {
"version": "0.1.2",
"resolved": "https://registry.npmmirror.com/ecc-jsbn/-/ecc-jsbn-0.1.2.tgz",
@@ -292,6 +488,21 @@
"safe-buffer": "^5.0.1"
}
},
+ "node_modules/ee-first": {
+ "version": "1.1.1",
+ "resolved": "https://registry.npmmirror.com/ee-first/-/ee-first-1.1.1.tgz",
+ "integrity": "sha512-WMwm9LhRUo+WUaRN+vRuETqG89IgZphVSNkdFgeb6sS/E4OrDIN7t48CAewSHXc6C8lefD8KKfr5vY61brQlow==",
+ "license": "MIT"
+ },
+ "node_modules/encodeurl": {
+ "version": "2.0.0",
+ "resolved": "https://registry.npmmirror.com/encodeurl/-/encodeurl-2.0.0.tgz",
+ "integrity": "sha512-Q0n9HRi4m6JuGIV1eFlmvJB7ZEVxu93IrMyiMsGC0lrMJMWzRgx6WGquyfQgZVb31vhGgXnfmPNNXmxnOkRBrg==",
+ "license": "MIT",
+ "engines": {
+ "node": ">= 0.8"
+ }
+ },
"node_modules/env-paths": {
"version": "2.2.1",
"resolved": "https://registry.npmmirror.com/env-paths/-/env-paths-2.2.1.tgz",
@@ -301,6 +512,134 @@
"node": ">=6"
}
},
+ "node_modules/es-define-property": {
+ "version": "1.0.1",
+ "resolved": "https://registry.npmmirror.com/es-define-property/-/es-define-property-1.0.1.tgz",
+ "integrity": "sha512-e3nRfgfUZ4rNGL232gUgX06QNyyez04KdjFrF+LTRoOXmrOgFKDg4BCdsjW8EnT69eqdYGmRpJwiPVYNrCaW3g==",
+ "license": "MIT",
+ "engines": {
+ "node": ">= 0.4"
+ }
+ },
+ "node_modules/es-errors": {
+ "version": "1.3.0",
+ "resolved": "https://registry.npmmirror.com/es-errors/-/es-errors-1.3.0.tgz",
+ "integrity": "sha512-Zf5H2Kxt2xjTvbJvP2ZWLEICxA6j+hAmMzIlypy4xcBg1vKVnx89Wy0GbS+kf5cwCVFFzdCFh2XSCFNULS6csw==",
+ "license": "MIT",
+ "engines": {
+ "node": ">= 0.4"
+ }
+ },
+ "node_modules/es-object-atoms": {
+ "version": "1.1.1",
+ "resolved": "https://registry.npmmirror.com/es-object-atoms/-/es-object-atoms-1.1.1.tgz",
+ "integrity": "sha512-FGgH2h8zKNim9ljj7dankFPcICIK9Cp5bm+c2gQSYePhpaG5+esrLODihIorn+Pe6FGJzWhXQotPv73jTaldXA==",
+ "license": "MIT",
+ "dependencies": {
+ "es-errors": "^1.3.0"
+ },
+ "engines": {
+ "node": ">= 0.4"
+ }
+ },
+ "node_modules/escape-html": {
+ "version": "1.0.3",
+ "resolved": "https://registry.npmmirror.com/escape-html/-/escape-html-1.0.3.tgz",
+ "integrity": "sha512-NiSupZ4OeuGwr68lGIeym/ksIZMJodUGOSCZ/FSnTxcrekbvqrgdUxlJOMpijaKZVjAJrWrGs/6Jy8OMuyj9ow==",
+ "license": "MIT"
+ },
+ "node_modules/etag": {
+ "version": "1.8.1",
+ "resolved": "https://registry.npmmirror.com/etag/-/etag-1.8.1.tgz",
+ "integrity": "sha512-aIL5Fx7mawVa300al2BnEE4iNvo1qETxLrPI/o05L7z6go7fCw1J6EQmbK4FmJ2AS7kgVF/KEZWufBfdClMcPg==",
+ "license": "MIT",
+ "engines": {
+ "node": ">= 0.6"
+ }
+ },
+ "node_modules/express": {
+ "version": "5.2.1",
+ "resolved": "https://registry.npmmirror.com/express/-/express-5.2.1.tgz",
+ "integrity": "sha512-hIS4idWWai69NezIdRt2xFVofaF4j+6INOpJlVOLDO8zXGpUVEVzIYk12UUi2JzjEzWL3IOAxcTubgz9Po0yXw==",
+ "license": "MIT",
+ "dependencies": {
+ "accepts": "^2.0.0",
+ "body-parser": "^2.2.1",
+ "content-disposition": "^1.0.0",
+ "content-type": "^1.0.5",
+ "cookie": "^0.7.1",
+ "cookie-signature": "^1.2.1",
+ "debug": "^4.4.0",
+ "depd": "^2.0.0",
+ "encodeurl": "^2.0.0",
+ "escape-html": "^1.0.3",
+ "etag": "^1.8.1",
+ "finalhandler": "^2.1.0",
+ "fresh": "^2.0.0",
+ "http-errors": "^2.0.0",
+ "merge-descriptors": "^2.0.0",
+ "mime-types": "^3.0.0",
+ "on-finished": "^2.4.1",
+ "once": "^1.4.0",
+ "parseurl": "^1.3.3",
+ "proxy-addr": "^2.0.7",
+ "qs": "^6.14.0",
+ "range-parser": "^1.2.1",
+ "router": "^2.2.0",
+ "send": "^1.1.0",
+ "serve-static": "^2.2.0",
+ "statuses": "^2.0.1",
+ "type-is": "^2.0.1",
+ "vary": "^1.1.2"
+ },
+ "engines": {
+ "node": ">= 18"
+ },
+ "funding": {
+ "type": "opencollective",
+ "url": "https://opencollective.com/express"
+ }
+ },
+ "node_modules/express/node_modules/mime-db": {
+ "version": "1.54.0",
+ "resolved": "https://registry.npmmirror.com/mime-db/-/mime-db-1.54.0.tgz",
+ "integrity": "sha512-aU5EJuIN2WDemCcAp2vFBfp/m4EAhWJnUNSSw0ixs7/kXbd6Pg64EmwJkNdFhB8aWt1sH2CTXrLxo/iAGV3oPQ==",
+ "license": "MIT",
+ "engines": {
+ "node": ">= 0.6"
+ }
+ },
+ "node_modules/express/node_modules/mime-types": {
+ "version": "3.0.2",
+ "resolved": "https://registry.npmmirror.com/mime-types/-/mime-types-3.0.2.tgz",
+ "integrity": "sha512-Lbgzdk0h4juoQ9fCKXW4by0UJqj+nOOrI9MJ1sSj4nI8aI2eo1qmvQEie4VD1glsS250n15LsWsYtCugiStS5A==",
+ "license": "MIT",
+ "dependencies": {
+ "mime-db": "^1.54.0"
+ },
+ "engines": {
+ "node": ">=18"
+ },
+ "funding": {
+ "type": "opencollective",
+ "url": "https://opencollective.com/express"
+ }
+ },
+ "node_modules/express/node_modules/qs": {
+ "version": "6.15.1",
+ "resolved": "https://registry.npmmirror.com/qs/-/qs-6.15.1.tgz",
+ "integrity": "sha512-6YHEFRL9mfgcAvql/XhwTvf5jKcOiiupt2FiJxHkiX1z4j7WL8J/jRHYLluORvc1XxB5rV20KoeK00gVJamspg==",
+ "license": "BSD-3-Clause",
+ "dependencies": {
+ "side-channel": "^1.1.0"
+ },
+ "engines": {
+ "node": ">=0.6"
+ },
+ "funding": {
+ "url": "https://github.com/sponsors/ljharb"
+ }
+ },
"node_modules/extend": {
"version": "3.0.2",
"resolved": "https://registry.npmmirror.com/extend/-/extend-3.0.2.tgz",
@@ -350,6 +689,27 @@
"fxparser": "src/cli/cli.js"
}
},
+ "node_modules/finalhandler": {
+ "version": "2.1.1",
+ "resolved": "https://registry.npmmirror.com/finalhandler/-/finalhandler-2.1.1.tgz",
+ "integrity": "sha512-S8KoZgRZN+a5rNwqTxlZZePjT/4cnm0ROV70LedRHZ0p8u9fRID0hJUZQpkKLzro8LfmC8sx23bY6tVNxv8pQA==",
+ "license": "MIT",
+ "dependencies": {
+ "debug": "^4.4.0",
+ "encodeurl": "^2.0.0",
+ "escape-html": "^1.0.3",
+ "on-finished": "^2.4.1",
+ "parseurl": "^1.3.3",
+ "statuses": "^2.0.1"
+ },
+ "engines": {
+ "node": ">= 18.0.0"
+ },
+ "funding": {
+ "type": "opencollective",
+ "url": "https://opencollective.com/express"
+ }
+ },
"node_modules/find-up": {
"version": "3.0.0",
"resolved": "https://registry.npmmirror.com/find-up/-/find-up-3.0.0.tgz",
@@ -385,6 +745,33 @@
"node": ">= 0.12"
}
},
+ "node_modules/forwarded": {
+ "version": "0.2.0",
+ "resolved": "https://registry.npmmirror.com/forwarded/-/forwarded-0.2.0.tgz",
+ "integrity": "sha512-buRG0fpBtRHSTCOASe6hD258tEubFoRLb4ZNA6NxMVHNw2gOcwHo9wyablzMzOA5z9xA9L1KNjk/Nt6MT9aYow==",
+ "license": "MIT",
+ "engines": {
+ "node": ">= 0.6"
+ }
+ },
+ "node_modules/fresh": {
+ "version": "2.0.0",
+ "resolved": "https://registry.npmmirror.com/fresh/-/fresh-2.0.0.tgz",
+ "integrity": "sha512-Rx/WycZ60HOaqLKAi6cHRKKI7zxWbJ31MhntmtwMoaTeF7XFH9hhBp8vITaMidfljRQ6eYWCKkaTK+ykVJHP2A==",
+ "license": "MIT",
+ "engines": {
+ "node": ">= 0.8"
+ }
+ },
+ "node_modules/function-bind": {
+ "version": "1.1.2",
+ "resolved": "https://registry.npmmirror.com/function-bind/-/function-bind-1.1.2.tgz",
+ "integrity": "sha512-7XHNxH7qX9xG5mIwxkhumTox/MIRNcOgDrxWsMt2pAr23WHp6MrRlN7FBSFpCpr+oVO0F744iUgR82nJMfG2SA==",
+ "license": "MIT",
+ "funding": {
+ "url": "https://github.com/sponsors/ljharb"
+ }
+ },
"node_modules/generate-function": {
"version": "2.3.1",
"resolved": "https://registry.npmmirror.com/generate-function/-/generate-function-2.3.1.tgz",
@@ -394,6 +781,43 @@
"is-property": "^1.0.2"
}
},
+ "node_modules/get-intrinsic": {
+ "version": "1.3.0",
+ "resolved": "https://registry.npmmirror.com/get-intrinsic/-/get-intrinsic-1.3.0.tgz",
+ "integrity": "sha512-9fSjSaos/fRIVIp+xSJlE6lfwhES7LNtKaCBIamHsjr2na1BiABJPo0mOjjz8GJDURarmCPGqaiVg5mfjb98CQ==",
+ "license": "MIT",
+ "dependencies": {
+ "call-bind-apply-helpers": "^1.0.2",
+ "es-define-property": "^1.0.1",
+ "es-errors": "^1.3.0",
+ "es-object-atoms": "^1.1.1",
+ "function-bind": "^1.1.2",
+ "get-proto": "^1.0.1",
+ "gopd": "^1.2.0",
+ "has-symbols": "^1.1.0",
+ "hasown": "^2.0.2",
+ "math-intrinsics": "^1.1.0"
+ },
+ "engines": {
+ "node": ">= 0.4"
+ },
+ "funding": {
+ "url": "https://github.com/sponsors/ljharb"
+ }
+ },
+ "node_modules/get-proto": {
+ "version": "1.0.1",
+ "resolved": "https://registry.npmmirror.com/get-proto/-/get-proto-1.0.1.tgz",
+ "integrity": "sha512-sTSfBjoXBp89JvIKIefqw7U2CCebsc74kiY6awiGogKtoSGbgjYE/G/+l9sF3MWFPNc9IcoOC4ODfKHfxFmp0g==",
+ "license": "MIT",
+ "dependencies": {
+ "dunder-proto": "^1.0.1",
+ "es-object-atoms": "^1.0.0"
+ },
+ "engines": {
+ "node": ">= 0.4"
+ }
+ },
"node_modules/getpass": {
"version": "0.1.7",
"resolved": "https://registry.npmmirror.com/getpass/-/getpass-0.1.7.tgz",
@@ -403,6 +827,18 @@
"assert-plus": "^1.0.0"
}
},
+ "node_modules/gopd": {
+ "version": "1.2.0",
+ "resolved": "https://registry.npmmirror.com/gopd/-/gopd-1.2.0.tgz",
+ "integrity": "sha512-ZUKRh6/kUFoAiTAtTYPZJ3hw9wNxx+BIBOijnlG9PnrJsCcSjs1wyyD6vJpaYtgnzDrKYRSqf3OO6Rfa93xsRg==",
+ "license": "MIT",
+ "engines": {
+ "node": ">= 0.4"
+ },
+ "funding": {
+ "url": "https://github.com/sponsors/ljharb"
+ }
+ },
"node_modules/har-schema": {
"version": "2.0.0",
"resolved": "https://registry.npmmirror.com/har-schema/-/har-schema-2.0.0.tgz",
@@ -448,6 +884,50 @@
"integrity": "sha512-xbbCH5dCYU5T8LcEhhuh7HJ88HXuW3qsI3Y0zOZFKfZEHcpWiHU/Jxzk629Brsab/mMiHQti9wMP+845RPe3Vg==",
"license": "MIT"
},
+ "node_modules/has-symbols": {
+ "version": "1.1.0",
+ "resolved": "https://registry.npmmirror.com/has-symbols/-/has-symbols-1.1.0.tgz",
+ "integrity": "sha512-1cDNdwJ2Jaohmb3sg4OmKaMBwuC48sYni5HUw2DvsC8LjGTLK9h+eb1X6RyuOHe4hT0ULCW68iomhjUoKUqlPQ==",
+ "license": "MIT",
+ "engines": {
+ "node": ">= 0.4"
+ },
+ "funding": {
+ "url": "https://github.com/sponsors/ljharb"
+ }
+ },
+ "node_modules/hasown": {
+ "version": "2.0.3",
+ "resolved": "https://registry.npmmirror.com/hasown/-/hasown-2.0.3.tgz",
+ "integrity": "sha512-ej4AhfhfL2Q2zpMmLo7U1Uv9+PyhIZpgQLGT1F9miIGmiCJIoCgSmczFdrc97mWT4kVY72KA+WnnhJ5pghSvSg==",
+ "license": "MIT",
+ "dependencies": {
+ "function-bind": "^1.1.2"
+ },
+ "engines": {
+ "node": ">= 0.4"
+ }
+ },
+ "node_modules/http-errors": {
+ "version": "2.0.1",
+ "resolved": "https://registry.npmmirror.com/http-errors/-/http-errors-2.0.1.tgz",
+ "integrity": "sha512-4FbRdAX+bSdmo4AUFuS0WNiPz8NgFt+r8ThgNWmlrjQjt1Q7ZR9+zTlce2859x4KSXrwIsaeTqDoKQmtP8pLmQ==",
+ "license": "MIT",
+ "dependencies": {
+ "depd": "~2.0.0",
+ "inherits": "~2.0.4",
+ "setprototypeof": "~1.2.0",
+ "statuses": "~2.0.2",
+ "toidentifier": "~1.0.1"
+ },
+ "engines": {
+ "node": ">= 0.8"
+ },
+ "funding": {
+ "type": "opencollective",
+ "url": "https://opencollective.com/express"
+ }
+ },
"node_modules/http-signature": {
"version": "1.2.0",
"resolved": "https://registry.npmmirror.com/http-signature/-/http-signature-1.2.0.tgz",
@@ -479,6 +959,21 @@
"url": "https://opencollective.com/express"
}
},
+ "node_modules/inherits": {
+ "version": "2.0.4",
+ "resolved": "https://registry.npmmirror.com/inherits/-/inherits-2.0.4.tgz",
+ "integrity": "sha512-k/vGaX4/Yla3WzyMCvTQOXYeIHvqOKtnqBduzTHpzpQZzAskKMhZ2K+EnBiSM9zGSoIFeMpXKxa4dYeZIQqewQ==",
+ "license": "ISC"
+ },
+ "node_modules/ipaddr.js": {
+ "version": "1.9.1",
+ "resolved": "https://registry.npmmirror.com/ipaddr.js/-/ipaddr.js-1.9.1.tgz",
+ "integrity": "sha512-0KI/607xoxSToH7GjN1FfSbLoU0+btTicjsQSWQlh/hZykN8KpmMf7uYwPW3R+akZ6R/w18ZlXSHBYXiYUPO3g==",
+ "license": "MIT",
+ "engines": {
+ "node": ">= 0.10"
+ }
+ },
"node_modules/is-obj": {
"version": "2.0.0",
"resolved": "https://registry.npmmirror.com/is-obj/-/is-obj-2.0.0.tgz",
@@ -488,6 +983,12 @@
"node": ">=8"
}
},
+ "node_modules/is-promise": {
+ "version": "4.0.0",
+ "resolved": "https://registry.npmmirror.com/is-promise/-/is-promise-4.0.0.tgz",
+ "integrity": "sha512-hvpoI6korhJMnej285dSg6nu1+e6uxs7zG3BYAm5byqDsgJNWwxzM6z6iZiAgQR4TJ30JmBTOwqZUw3WlyH3AQ==",
+ "license": "MIT"
+ },
"node_modules/is-property": {
"version": "1.0.2",
"resolved": "https://registry.npmmirror.com/is-property/-/is-property-1.0.2.tgz",
@@ -694,6 +1195,36 @@
"semver": "bin/semver.js"
}
},
+ "node_modules/math-intrinsics": {
+ "version": "1.1.0",
+ "resolved": "https://registry.npmmirror.com/math-intrinsics/-/math-intrinsics-1.1.0.tgz",
+ "integrity": "sha512-/IXtbwEk5HTPyEwyKX6hGkYXxM9nbj64B+ilVJnC/R6B0pH5G4V3b0pVbL7DBj4tkhBAppbQUlf6F6Xl9LHu1g==",
+ "license": "MIT",
+ "engines": {
+ "node": ">= 0.4"
+ }
+ },
+ "node_modules/media-typer": {
+ "version": "1.1.0",
+ "resolved": "https://registry.npmmirror.com/media-typer/-/media-typer-1.1.0.tgz",
+ "integrity": "sha512-aisnrDP4GNe06UcKFnV5bfMNPBUw4jsLGaWwWfnH3v02GnBuXX2MCVn5RbrWo0j3pczUilYblq7fQ7Nw2t5XKw==",
+ "license": "MIT",
+ "engines": {
+ "node": ">= 0.8"
+ }
+ },
+ "node_modules/merge-descriptors": {
+ "version": "2.0.0",
+ "resolved": "https://registry.npmmirror.com/merge-descriptors/-/merge-descriptors-2.0.0.tgz",
+ "integrity": "sha512-Snk314V5ayFLhp3fkUREub6WtjBfPdCPY1Ln8/8munuLuiYhsABgBVWsozAG+MWMbVEvcdcpbi9R7ww22l9Q3g==",
+ "license": "MIT",
+ "engines": {
+ "node": ">=18"
+ },
+ "funding": {
+ "url": "https://github.com/sponsors/sindresorhus"
+ }
+ },
"node_modules/mime-db": {
"version": "1.52.0",
"resolved": "https://registry.npmmirror.com/mime-db/-/mime-db-1.52.0.tgz",
@@ -764,6 +1295,15 @@
"node": ">=8.0.0"
}
},
+ "node_modules/negotiator": {
+ "version": "1.0.0",
+ "resolved": "https://registry.npmmirror.com/negotiator/-/negotiator-1.0.0.tgz",
+ "integrity": "sha512-8Ofs/AUQh8MaEcrlq5xOX0CQ9ypTF5dl78mjlMNfOK08fzpgTHQRQPBxcPlEtIw0yRpws+Zo/3r+5WRby7u3Gg==",
+ "license": "MIT",
+ "engines": {
+ "node": ">= 0.6"
+ }
+ },
"node_modules/oauth-sign": {
"version": "0.9.0",
"resolved": "https://registry.npmmirror.com/oauth-sign/-/oauth-sign-0.9.0.tgz",
@@ -773,6 +1313,39 @@
"node": "*"
}
},
+ "node_modules/object-inspect": {
+ "version": "1.13.4",
+ "resolved": "https://registry.npmmirror.com/object-inspect/-/object-inspect-1.13.4.tgz",
+ "integrity": "sha512-W67iLl4J2EXEGTbfeHCffrjDfitvLANg0UlX3wFUUSTx92KXRFegMHUVgSqE+wvhAbi4WqjGg9czysTV2Epbew==",
+ "license": "MIT",
+ "engines": {
+ "node": ">= 0.4"
+ },
+ "funding": {
+ "url": "https://github.com/sponsors/ljharb"
+ }
+ },
+ "node_modules/on-finished": {
+ "version": "2.4.1",
+ "resolved": "https://registry.npmmirror.com/on-finished/-/on-finished-2.4.1.tgz",
+ "integrity": "sha512-oVlzkg3ENAhCk2zdv7IJwd/QUD4z2RxRwpkcGY8psCVcCYZNq4wYnVWALHM+brtuJjePWiYF/ClmuDr8Ch5+kg==",
+ "license": "MIT",
+ "dependencies": {
+ "ee-first": "1.1.1"
+ },
+ "engines": {
+ "node": ">= 0.8"
+ }
+ },
+ "node_modules/once": {
+ "version": "1.4.0",
+ "resolved": "https://registry.npmmirror.com/once/-/once-1.4.0.tgz",
+ "integrity": "sha512-lNaJgI+2Q5URQBkccEKHTQOPaXdUxnZZElQTZY0MFUAuaEqe1E+Nyvgdz/aIyNi6Z9MzO5dv1H8n58/GELp3+w==",
+ "license": "ISC",
+ "dependencies": {
+ "wrappy": "1"
+ }
+ },
"node_modules/onetime": {
"version": "5.1.2",
"resolved": "https://registry.npmmirror.com/onetime/-/onetime-5.1.2.tgz",
@@ -833,6 +1406,15 @@
"node": ">=6"
}
},
+ "node_modules/parseurl": {
+ "version": "1.3.3",
+ "resolved": "https://registry.npmmirror.com/parseurl/-/parseurl-1.3.3.tgz",
+ "integrity": "sha512-CiyeOxFT/JZyN5m0z9PfXw4SCBJ6Sygz1Dpl0wqjlhDEGGBP1GnsUVEL0p63hoG1fcj3fHynXi9NYO4nWOL+qQ==",
+ "license": "MIT",
+ "engines": {
+ "node": ">= 0.8"
+ }
+ },
"node_modules/path-exists": {
"version": "3.0.0",
"resolved": "https://registry.npmmirror.com/path-exists/-/path-exists-3.0.0.tgz",
@@ -842,6 +1424,16 @@
"node": ">=4"
}
},
+ "node_modules/path-to-regexp": {
+ "version": "8.4.2",
+ "resolved": "https://registry.npmmirror.com/path-to-regexp/-/path-to-regexp-8.4.2.tgz",
+ "integrity": "sha512-qRcuIdP69NPm4qbACK+aDogI5CBDMi1jKe0ry5rSQJz8JVLsC7jV8XpiJjGRLLol3N+R5ihGYcrPLTno6pAdBA==",
+ "license": "MIT",
+ "funding": {
+ "type": "opencollective",
+ "url": "https://opencollective.com/express"
+ }
+ },
"node_modules/performance-now": {
"version": "2.1.0",
"resolved": "https://registry.npmmirror.com/performance-now/-/performance-now-2.1.0.tgz",
@@ -860,6 +1452,19 @@
"node": ">=8"
}
},
+ "node_modules/proxy-addr": {
+ "version": "2.0.7",
+ "resolved": "https://registry.npmmirror.com/proxy-addr/-/proxy-addr-2.0.7.tgz",
+ "integrity": "sha512-llQsMLSUDUPT44jdrU/O37qlnifitDP+ZwrmmZcoSKyLKvtZxpyV0n2/bD/N4tBAAZ/gJEdZU7KMraoK1+XYAg==",
+ "license": "MIT",
+ "dependencies": {
+ "forwarded": "0.2.0",
+ "ipaddr.js": "1.9.1"
+ },
+ "engines": {
+ "node": ">= 0.10"
+ }
+ },
"node_modules/psl": {
"version": "1.15.0",
"resolved": "https://registry.npmmirror.com/psl/-/psl-1.15.0.tgz",
@@ -890,6 +1495,30 @@
"node": ">=0.6"
}
},
+ "node_modules/range-parser": {
+ "version": "1.2.1",
+ "resolved": "https://registry.npmmirror.com/range-parser/-/range-parser-1.2.1.tgz",
+ "integrity": "sha512-Hrgsx+orqoygnmhFbKaHE6c296J+HTAQXoxEF6gNupROmmGJRoyzfG3ccAveqCBrwr/2yxQ5BVd/GTl5agOwSg==",
+ "license": "MIT",
+ "engines": {
+ "node": ">= 0.6"
+ }
+ },
+ "node_modules/raw-body": {
+ "version": "3.0.2",
+ "resolved": "https://registry.npmmirror.com/raw-body/-/raw-body-3.0.2.tgz",
+ "integrity": "sha512-K5zQjDllxWkf7Z5xJdV0/B0WTNqx6vxG70zJE4N0kBs4LovmEYWJzQGxC9bS9RAKu3bgM40lrd5zoLJ12MQ5BA==",
+ "license": "MIT",
+ "dependencies": {
+ "bytes": "~3.1.2",
+ "http-errors": "~2.0.1",
+ "iconv-lite": "~0.7.0",
+ "unpipe": "~1.0.0"
+ },
+ "engines": {
+ "node": ">= 0.10"
+ }
+ },
"node_modules/request": {
"version": "2.88.2",
"resolved": "https://registry.npmmirror.com/request/-/request-2.88.2.tgz",
@@ -931,6 +1560,22 @@
"node": ">=0.10.0"
}
},
+ "node_modules/router": {
+ "version": "2.2.0",
+ "resolved": "https://registry.npmmirror.com/router/-/router-2.2.0.tgz",
+ "integrity": "sha512-nLTrUKm2UyiL7rlhapu/Zl45FwNgkZGaCpZbIHajDYgwlJCOzLSk+cIPAnsEqV955GjILJnKbdQC1nVPz+gAYQ==",
+ "license": "MIT",
+ "dependencies": {
+ "debug": "^4.4.0",
+ "depd": "^2.0.0",
+ "is-promise": "^4.0.0",
+ "parseurl": "^1.3.3",
+ "path-to-regexp": "^8.0.0"
+ },
+ "engines": {
+ "node": ">= 18"
+ }
+ },
"node_modules/safe-buffer": {
"version": "5.2.1",
"resolved": "https://registry.npmmirror.com/safe-buffer/-/safe-buffer-5.2.1.tgz",
@@ -969,6 +1614,154 @@
"node": ">=10"
}
},
+ "node_modules/send": {
+ "version": "1.2.1",
+ "resolved": "https://registry.npmmirror.com/send/-/send-1.2.1.tgz",
+ "integrity": "sha512-1gnZf7DFcoIcajTjTwjwuDjzuz4PPcY2StKPlsGAQ1+YH20IRVrBaXSWmdjowTJ6u8Rc01PoYOGHXfP1mYcZNQ==",
+ "license": "MIT",
+ "dependencies": {
+ "debug": "^4.4.3",
+ "encodeurl": "^2.0.0",
+ "escape-html": "^1.0.3",
+ "etag": "^1.8.1",
+ "fresh": "^2.0.0",
+ "http-errors": "^2.0.1",
+ "mime-types": "^3.0.2",
+ "ms": "^2.1.3",
+ "on-finished": "^2.4.1",
+ "range-parser": "^1.2.1",
+ "statuses": "^2.0.2"
+ },
+ "engines": {
+ "node": ">= 18"
+ },
+ "funding": {
+ "type": "opencollective",
+ "url": "https://opencollective.com/express"
+ }
+ },
+ "node_modules/send/node_modules/mime-db": {
+ "version": "1.54.0",
+ "resolved": "https://registry.npmmirror.com/mime-db/-/mime-db-1.54.0.tgz",
+ "integrity": "sha512-aU5EJuIN2WDemCcAp2vFBfp/m4EAhWJnUNSSw0ixs7/kXbd6Pg64EmwJkNdFhB8aWt1sH2CTXrLxo/iAGV3oPQ==",
+ "license": "MIT",
+ "engines": {
+ "node": ">= 0.6"
+ }
+ },
+ "node_modules/send/node_modules/mime-types": {
+ "version": "3.0.2",
+ "resolved": "https://registry.npmmirror.com/mime-types/-/mime-types-3.0.2.tgz",
+ "integrity": "sha512-Lbgzdk0h4juoQ9fCKXW4by0UJqj+nOOrI9MJ1sSj4nI8aI2eo1qmvQEie4VD1glsS250n15LsWsYtCugiStS5A==",
+ "license": "MIT",
+ "dependencies": {
+ "mime-db": "^1.54.0"
+ },
+ "engines": {
+ "node": ">=18"
+ },
+ "funding": {
+ "type": "opencollective",
+ "url": "https://opencollective.com/express"
+ }
+ },
+ "node_modules/serve-static": {
+ "version": "2.2.1",
+ "resolved": "https://registry.npmmirror.com/serve-static/-/serve-static-2.2.1.tgz",
+ "integrity": "sha512-xRXBn0pPqQTVQiC8wyQrKs2MOlX24zQ0POGaj0kultvoOCstBQM5yvOhAVSUwOMjQtTvsPWoNCHfPGwaaQJhTw==",
+ "license": "MIT",
+ "dependencies": {
+ "encodeurl": "^2.0.0",
+ "escape-html": "^1.0.3",
+ "parseurl": "^1.3.3",
+ "send": "^1.2.0"
+ },
+ "engines": {
+ "node": ">= 18"
+ },
+ "funding": {
+ "type": "opencollective",
+ "url": "https://opencollective.com/express"
+ }
+ },
+ "node_modules/setprototypeof": {
+ "version": "1.2.0",
+ "resolved": "https://registry.npmmirror.com/setprototypeof/-/setprototypeof-1.2.0.tgz",
+ "integrity": "sha512-E5LDX7Wrp85Kil5bhZv46j8jOeboKq5JMmYM3gVGdGH8xFpPWXUMsNrlODCrkoxMEeNi/XZIwuRvY4XNwYMJpw==",
+ "license": "ISC"
+ },
+ "node_modules/side-channel": {
+ "version": "1.1.0",
+ "resolved": "https://registry.npmmirror.com/side-channel/-/side-channel-1.1.0.tgz",
+ "integrity": "sha512-ZX99e6tRweoUXqR+VBrslhda51Nh5MTQwou5tnUDgbtyM0dBgmhEDtWGP/xbKn6hqfPRHujUNwz5fy/wbbhnpw==",
+ "license": "MIT",
+ "dependencies": {
+ "es-errors": "^1.3.0",
+ "object-inspect": "^1.13.3",
+ "side-channel-list": "^1.0.0",
+ "side-channel-map": "^1.0.1",
+ "side-channel-weakmap": "^1.0.2"
+ },
+ "engines": {
+ "node": ">= 0.4"
+ },
+ "funding": {
+ "url": "https://github.com/sponsors/ljharb"
+ }
+ },
+ "node_modules/side-channel-list": {
+ "version": "1.0.1",
+ "resolved": "https://registry.npmmirror.com/side-channel-list/-/side-channel-list-1.0.1.tgz",
+ "integrity": "sha512-mjn/0bi/oUURjc5Xl7IaWi/OJJJumuoJFQJfDDyO46+hBWsfaVM65TBHq2eoZBhzl9EchxOijpkbRC8SVBQU0w==",
+ "license": "MIT",
+ "dependencies": {
+ "es-errors": "^1.3.0",
+ "object-inspect": "^1.13.4"
+ },
+ "engines": {
+ "node": ">= 0.4"
+ },
+ "funding": {
+ "url": "https://github.com/sponsors/ljharb"
+ }
+ },
+ "node_modules/side-channel-map": {
+ "version": "1.0.1",
+ "resolved": "https://registry.npmmirror.com/side-channel-map/-/side-channel-map-1.0.1.tgz",
+ "integrity": "sha512-VCjCNfgMsby3tTdo02nbjtM/ewra6jPHmpThenkTYh8pG9ucZ/1P8So4u4FGBek/BjpOVsDCMoLA/iuBKIFXRA==",
+ "license": "MIT",
+ "dependencies": {
+ "call-bound": "^1.0.2",
+ "es-errors": "^1.3.0",
+ "get-intrinsic": "^1.2.5",
+ "object-inspect": "^1.13.3"
+ },
+ "engines": {
+ "node": ">= 0.4"
+ },
+ "funding": {
+ "url": "https://github.com/sponsors/ljharb"
+ }
+ },
+ "node_modules/side-channel-weakmap": {
+ "version": "1.0.2",
+ "resolved": "https://registry.npmmirror.com/side-channel-weakmap/-/side-channel-weakmap-1.0.2.tgz",
+ "integrity": "sha512-WPS/HvHQTYnHisLo9McqBHOJk2FkHO/tlpvldyrnem4aeQp4hai3gythswg6p01oSoTl58rcpiFAjF2br2Ak2A==",
+ "license": "MIT",
+ "dependencies": {
+ "call-bound": "^1.0.2",
+ "es-errors": "^1.3.0",
+ "get-intrinsic": "^1.2.5",
+ "object-inspect": "^1.13.3",
+ "side-channel-map": "^1.0.1"
+ },
+ "engines": {
+ "node": ">= 0.4"
+ },
+ "funding": {
+ "url": "https://github.com/sponsors/ljharb"
+ }
+ },
"node_modules/sql-escaper": {
"version": "1.3.3",
"resolved": "https://registry.npmmirror.com/sql-escaper/-/sql-escaper-1.3.3.tgz",
@@ -1009,6 +1802,15 @@
"node": ">=0.10.0"
}
},
+ "node_modules/statuses": {
+ "version": "2.0.2",
+ "resolved": "https://registry.npmmirror.com/statuses/-/statuses-2.0.2.tgz",
+ "integrity": "sha512-DvEy55V3DB7uknRo+4iOGT5fP1slR8wQohVdknigZPMpMstaKJQWhwiYBACJE3Ul2pTnATihhBYnRhZQHGBiRw==",
+ "license": "MIT",
+ "engines": {
+ "node": ">= 0.8"
+ }
+ },
"node_modules/strnum": {
"version": "1.1.2",
"resolved": "https://registry.npmmirror.com/strnum/-/strnum-1.1.2.tgz",
@@ -1021,6 +1823,15 @@
],
"license": "MIT"
},
+ "node_modules/toidentifier": {
+ "version": "1.0.1",
+ "resolved": "https://registry.npmmirror.com/toidentifier/-/toidentifier-1.0.1.tgz",
+ "integrity": "sha512-o5sSPKEkg/DIQNmH43V0/uerLrpzVedkUh8tGNvaeXpfpuwjKenlSox/2O/BTlZUtEe+JG7s5YhEz608PlAHRA==",
+ "license": "MIT",
+ "engines": {
+ "node": ">=0.6"
+ }
+ },
"node_modules/tough-cookie": {
"version": "2.5.0",
"resolved": "https://registry.npmmirror.com/tough-cookie/-/tough-cookie-2.5.0.tgz",
@@ -1052,6 +1863,45 @@
"integrity": "sha512-KXXFFdAbFXY4geFIwoyNK+f5Z1b7swfXABfL7HXCmoIWMKU3dmS26672A4EeQtDzLKy7SXmfBu51JolvEKwtGA==",
"license": "Unlicense"
},
+ "node_modules/type-is": {
+ "version": "2.0.1",
+ "resolved": "https://registry.npmmirror.com/type-is/-/type-is-2.0.1.tgz",
+ "integrity": "sha512-OZs6gsjF4vMp32qrCbiVSkrFmXtG/AZhY3t0iAMrMBiAZyV9oALtXO8hsrHbMXF9x6L3grlFuwW2oAz7cav+Gw==",
+ "license": "MIT",
+ "dependencies": {
+ "content-type": "^1.0.5",
+ "media-typer": "^1.1.0",
+ "mime-types": "^3.0.0"
+ },
+ "engines": {
+ "node": ">= 0.6"
+ }
+ },
+ "node_modules/type-is/node_modules/mime-db": {
+ "version": "1.54.0",
+ "resolved": "https://registry.npmmirror.com/mime-db/-/mime-db-1.54.0.tgz",
+ "integrity": "sha512-aU5EJuIN2WDemCcAp2vFBfp/m4EAhWJnUNSSw0ixs7/kXbd6Pg64EmwJkNdFhB8aWt1sH2CTXrLxo/iAGV3oPQ==",
+ "license": "MIT",
+ "engines": {
+ "node": ">= 0.6"
+ }
+ },
+ "node_modules/type-is/node_modules/mime-types": {
+ "version": "3.0.2",
+ "resolved": "https://registry.npmmirror.com/mime-types/-/mime-types-3.0.2.tgz",
+ "integrity": "sha512-Lbgzdk0h4juoQ9fCKXW4by0UJqj+nOOrI9MJ1sSj4nI8aI2eo1qmvQEie4VD1glsS250n15LsWsYtCugiStS5A==",
+ "license": "MIT",
+ "dependencies": {
+ "mime-db": "^1.54.0"
+ },
+ "engines": {
+ "node": ">=18"
+ },
+ "funding": {
+ "type": "opencollective",
+ "url": "https://opencollective.com/express"
+ }
+ },
"node_modules/undici-types": {
"version": "7.19.2",
"resolved": "https://registry.npmmirror.com/undici-types/-/undici-types-7.19.2.tgz",
@@ -1059,6 +1909,15 @@
"license": "MIT",
"peer": true
},
+ "node_modules/unpipe": {
+ "version": "1.0.0",
+ "resolved": "https://registry.npmmirror.com/unpipe/-/unpipe-1.0.0.tgz",
+ "integrity": "sha512-pjy2bYhSsufwWlKwPc+l3cN7+wuJlK6uz0YdJEOlQDbl6jo/YlPi4mb8agUkVC8BF7V8NuzeyPNqRksA3hztKQ==",
+ "license": "MIT",
+ "engines": {
+ "node": ">= 0.8"
+ }
+ },
"node_modules/uri-js": {
"version": "4.4.1",
"resolved": "https://registry.npmmirror.com/uri-js/-/uri-js-4.4.1.tgz",
@@ -1078,6 +1937,15 @@
"uuid": "bin/uuid"
}
},
+ "node_modules/vary": {
+ "version": "1.1.2",
+ "resolved": "https://registry.npmmirror.com/vary/-/vary-1.1.2.tgz",
+ "integrity": "sha512-BNGbWLfd0eUPabhkXUVm0j8uuvREyTh5ovRa/dyow/BqAbZJyC+5fU+IzQOzmAKzYqYRAISoRhdQr3eIZ/PXqg==",
+ "license": "MIT",
+ "engines": {
+ "node": ">= 0.8"
+ }
+ },
"node_modules/verror": {
"version": "1.10.0",
"resolved": "https://registry.npmmirror.com/verror/-/verror-1.10.0.tgz",
@@ -1091,6 +1959,12 @@
"core-util-is": "1.0.2",
"extsprintf": "^1.2.0"
}
+ },
+ "node_modules/wrappy": {
+ "version": "1.0.2",
+ "resolved": "https://registry.npmmirror.com/wrappy/-/wrappy-1.0.2.tgz",
+ "integrity": "sha512-l4Sp/DRseor9wL6EvV2+TuQn63dMkPjZ/sp9XkghTEbV9KlPS1xUsZ3u7/IQO4wxtcFB4bgpQPRcR3QCvezPcQ==",
+ "license": "ISC"
}
}
}
diff --git a/server/package.json b/server/package.json
index 8a4fa1a..ae6ee77 100644
--- a/server/package.json
+++ b/server/package.json
@@ -12,8 +12,8 @@
"bcryptjs": "^2.4.3",
"cos-nodejs-sdk-v5": "^2.14.7",
"dotenv": "^16.4.5",
+ "express": "^5.2.1",
"jsonwebtoken": "^9.0.2",
"mysql2": "^3.11.3"
- },
- "devDependencies": {}
+ }
}
diff --git a/server/scripts/local-server.js b/server/scripts/local-server.js
index e821908..f1ef983 100644
--- a/server/scripts/local-server.js
+++ b/server/scripts/local-server.js
@@ -1,27 +1,8 @@
require('dotenv').config({ path: require('path').join(__dirname, '..', '.env') })
-const http = require('http')
const config = require('../src/config')
-const { handle } = require('../src/app')
+const app = require('../src/app')
-const server = http.createServer(async (req, res) => {
- const chunks = []
- req.on('data', chunk => chunks.push(chunk))
- req.on('end', async () => {
- const url = new URL(req.url, 'http://localhost')
- const event = {
- httpMethod: req.method,
- path: url.pathname,
- headers: req.headers,
- queryStringParameters: Object.fromEntries(url.searchParams.entries()),
- body: Buffer.concat(chunks).toString('utf8')
- }
- const result = await handle(event)
- res.writeHead(result.statusCode, result.headers)
- res.end(result.body || '')
- })
-})
-
-server.listen(config.port, () => {
- console.log('SCF local server listening on http://localhost:' + config.port)
+app.listen(config.port, () => {
+ console.log('Server listening on http://localhost:' + config.port)
})
diff --git a/server/src/app.js b/server/src/app.js
index 59c1a31..8373ec0 100644
--- a/server/src/app.js
+++ b/server/src/app.js
@@ -1,34 +1,35 @@
-const Router = require('./lib/router')
-const { createContext } = require('./lib/request')
-const { ok, fail, http } = require('./lib/response')
+const express = require('express')
+const { ok, fail } = require('./lib/response')
+const { authMiddleware } = require('./middleware/auth')
-const router = new Router()
+const app = express()
-require('./routes/auth')(router)
-require('./routes/user')(router)
-require('./routes/device')(router)
-require('./routes/subscription')(router)
-require('./routes/treatment')(router)
-require('./routes/admin')(router)
-require('./routes/firmware')(router)
+app.use(express.json())
+app.use((req, res, next) => {
+ res.header('Access-Control-Allow-Origin', '*')
+ res.header('Access-Control-Allow-Headers', 'Content-Type, Authorization, X-Device-Id, X-App-Version, X-Platform')
+ res.header('Access-Control-Allow-Methods', 'GET, POST, PUT, DELETE, OPTIONS')
+ if (req.method === 'OPTIONS') return res.sendStatus(204)
+ next()
+})
-async function handle(event) {
- const ctx = createContext(event || {})
- if (ctx.method === 'OPTIONS') return http(204, {})
- if (ctx.path === '/health') return http(200, ok({ status: 'ok' }))
+app.use(authMiddleware)
- const match = router.match(ctx.method, ctx.path)
- if (!match) return http(404, fail(404, 'not_found'))
+app.get('/health', (req, res) => res.json(ok({ status: 'ok' })))
- ctx.params = match.params
+app.use('/api/v1', require('./routes/auth'))
+app.use('/api/v1', require('./routes/user'))
+app.use('/api/v1', require('./routes/device'))
+app.use('/api/v1', require('./routes/subscription'))
+app.use('/api/v1', require('./routes/treatment'))
+app.use('/api/v1/admin', require('./routes/admin'))
+app.use('/api/v1', require('./routes/firmware'))
- try {
- const body = await match.handler(ctx)
- return http(200, body)
- } catch (err) {
- console.error('[ERROR]', ctx.method, ctx.path, err.code || '', err.sqlMessage || err.message, err.stack)
- return http(500, fail(3001, 'server_error'))
- }
-}
+app.use((req, res) => res.status(404).json(fail(404, 'not_found')))
-module.exports = { handle }
+app.use((err, req, res, _next) => {
+ console.error('[ERROR]', req.method, req.path, err.code || '', err.sqlMessage || err.message, err.stack)
+ res.status(500).json(fail(3001, 'server_error'))
+})
+
+module.exports = app
diff --git a/server/src/dao/admin.dao.js b/server/src/dao/admin.dao.js
new file mode 100644
index 0000000..d62f1ce
--- /dev/null
+++ b/server/src/dao/admin.dao.js
@@ -0,0 +1,93 @@
+const { query, one } = require('../lib/db')
+
+/**
+ * Find admin account by username (active only)
+ * @param {string} username
+ * @returns {Promise