feat: init project with miniprogram, cloud functions and admin console
这个提交包含在:
@@ -0,0 +1,57 @@
|
||||
var http = require('./utils/request')
|
||||
|
||||
App({
|
||||
globalData: {
|
||||
userInfo: null,
|
||||
userId: null,
|
||||
connectedDevice: null,
|
||||
currentTreatment: null
|
||||
},
|
||||
|
||||
onLaunch: function () {
|
||||
this.checkLogin()
|
||||
},
|
||||
|
||||
checkLogin: function () {
|
||||
var token = wx.getStorageSync('token')
|
||||
if (!token) {
|
||||
this.globalData.userInfo = null
|
||||
return
|
||||
}
|
||||
this.loadProfile()
|
||||
},
|
||||
|
||||
loadProfile: function () {
|
||||
var self = this
|
||||
http.get('/api/v1/user/profile').then(function (profile) {
|
||||
self.globalData.userInfo = profile
|
||||
self.globalData.userId = String(profile.user_id)
|
||||
}).catch(function () {
|
||||
wx.removeStorageSync('token')
|
||||
wx.reLaunch({ url: '/pages/login/login' })
|
||||
})
|
||||
},
|
||||
|
||||
doLogin: function () {
|
||||
return new Promise(function (resolve, reject) {
|
||||
wx.login({
|
||||
success: function (res) {
|
||||
if (!res.code) {
|
||||
reject({ message: 'wx.login failed' })
|
||||
return
|
||||
}
|
||||
http.post('/api/v1/auth/login', {
|
||||
code: res.code
|
||||
}).then(function (data) {
|
||||
wx.setStorageSync('token', data.token)
|
||||
var app = getApp()
|
||||
app.globalData.userInfo = data.user_info
|
||||
app.globalData.userId = data.user_id
|
||||
resolve(data)
|
||||
}).catch(reject)
|
||||
},
|
||||
fail: reject
|
||||
})
|
||||
})
|
||||
}
|
||||
})
|
||||
@@ -0,0 +1,62 @@
|
||||
{
|
||||
"pages": [
|
||||
"pages/index/index",
|
||||
"pages/login/login",
|
||||
"pages/scan/scan",
|
||||
"pages/ble-connect/ble-connect",
|
||||
"pages/bind-success/bind-success",
|
||||
"pages/wear-check/wear-check",
|
||||
"pages/treatment-setup/treatment-setup",
|
||||
"pages/auto-scan/auto-scan",
|
||||
"pages/treating/treating",
|
||||
"pages/treatment-done/treatment-done",
|
||||
"pages/subscribe-prompt/subscribe-prompt",
|
||||
"pages/subscribe-plans/subscribe-plans",
|
||||
"pages/subscribe-success/subscribe-success",
|
||||
"pages/profile/profile",
|
||||
"pages/history/history"
|
||||
],
|
||||
"window": {
|
||||
"navigationBarBackgroundColor": "#ffffff",
|
||||
"navigationBarTitleText": "光子美容仪",
|
||||
"navigationBarTextStyle": "black",
|
||||
"backgroundColor": "#f5f5f5"
|
||||
},
|
||||
"tabBar": {
|
||||
"color": "#999999",
|
||||
"selectedColor": "#333333",
|
||||
"backgroundColor": "#ffffff",
|
||||
"borderStyle": "black",
|
||||
"list": [
|
||||
{
|
||||
"pagePath": "pages/index/index",
|
||||
"text": "首页"
|
||||
},
|
||||
{
|
||||
"pagePath": "pages/history/history",
|
||||
"text": "护理记录"
|
||||
},
|
||||
{
|
||||
"pagePath": "pages/profile/profile",
|
||||
"text": "我的"
|
||||
}
|
||||
]
|
||||
},
|
||||
"permission": {
|
||||
"scope.bluetooth": {
|
||||
"desc": "用于连接光子美容仪设备"
|
||||
},
|
||||
"scope.userLocation": {
|
||||
"desc": "蓝牙连接需要位置权限"
|
||||
},
|
||||
"scope.camera": {
|
||||
"desc": "用于扫描设备二维码"
|
||||
}
|
||||
},
|
||||
"requiredPrivateInfos": [
|
||||
"getLocation",
|
||||
"chooseLocation"
|
||||
],
|
||||
"style": "v2",
|
||||
"sitemapLocation": "sitemap.json"
|
||||
}
|
||||
+144
@@ -0,0 +1,144 @@
|
||||
page {
|
||||
background-color: #f5f5f5;
|
||||
font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, 'Helvetica Neue', Arial, sans-serif;
|
||||
font-size: 28rpx;
|
||||
color: #333333;
|
||||
}
|
||||
|
||||
.container {
|
||||
padding: 24rpx;
|
||||
min-height: 100vh;
|
||||
}
|
||||
|
||||
.section-title {
|
||||
font-size: 32rpx;
|
||||
font-weight: 600;
|
||||
margin-bottom: 20rpx;
|
||||
}
|
||||
|
||||
.card {
|
||||
background-color: #ffffff;
|
||||
border-radius: 16rpx;
|
||||
padding: 32rpx;
|
||||
margin-bottom: 20rpx;
|
||||
box-shadow: 0 2rpx 8rpx rgba(0, 0, 0, 0.05);
|
||||
}
|
||||
|
||||
.btn-primary {
|
||||
background-color: #333333;
|
||||
color: #ffffff;
|
||||
border-radius: 12rpx;
|
||||
padding: 24rpx 40rpx;
|
||||
text-align: center;
|
||||
font-size: 30rpx;
|
||||
font-weight: 500;
|
||||
}
|
||||
|
||||
.btn-primary:active {
|
||||
opacity: 0.8;
|
||||
}
|
||||
|
||||
.btn-secondary {
|
||||
background-color: #ffffff;
|
||||
color: #333333;
|
||||
border: 2rpx solid #333333;
|
||||
border-radius: 12rpx;
|
||||
padding: 24rpx 40rpx;
|
||||
text-align: center;
|
||||
font-size: 30rpx;
|
||||
}
|
||||
|
||||
.text-muted {
|
||||
color: #999999;
|
||||
}
|
||||
|
||||
.text-center {
|
||||
text-align: center;
|
||||
}
|
||||
|
||||
.text-primary {
|
||||
color: #333333;
|
||||
}
|
||||
|
||||
.text-success {
|
||||
color: #52c41a;
|
||||
}
|
||||
|
||||
.text-warning {
|
||||
color: #faad14;
|
||||
}
|
||||
|
||||
.text-error {
|
||||
color: #ff4d4f;
|
||||
}
|
||||
|
||||
.mt-10 { margin-top: 10rpx; }
|
||||
.mt-20 { margin-top: 20rpx; }
|
||||
.mt-30 { margin-top: 30rpx; }
|
||||
.mb-10 { margin-bottom: 10rpx; }
|
||||
.mb-20 { margin-bottom: 20rpx; }
|
||||
.mb-30 { margin-bottom: 30rpx; }
|
||||
.p-20 { padding: 20rpx; }
|
||||
|
||||
.flex-row {
|
||||
display: flex;
|
||||
flex-direction: row;
|
||||
align-items: center;
|
||||
}
|
||||
|
||||
.flex-between {
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
align-items: center;
|
||||
}
|
||||
|
||||
.flex-center {
|
||||
display: flex;
|
||||
justify-content: center;
|
||||
align-items: center;
|
||||
}
|
||||
|
||||
.flex-1 {
|
||||
flex: 1;
|
||||
}
|
||||
|
||||
.divider {
|
||||
height: 1rpx;
|
||||
background-color: #eeeeee;
|
||||
margin: 20rpx 0;
|
||||
}
|
||||
|
||||
.badge {
|
||||
display: inline-block;
|
||||
padding: 4rpx 16rpx;
|
||||
border-radius: 8rpx;
|
||||
font-size: 22rpx;
|
||||
color: #ffffff;
|
||||
}
|
||||
|
||||
.badge-success {
|
||||
background-color: #52c41a;
|
||||
}
|
||||
|
||||
.badge-error {
|
||||
background-color: #ff4d4f;
|
||||
}
|
||||
|
||||
.badge-warning {
|
||||
background-color: #faad14;
|
||||
}
|
||||
|
||||
.empty-state {
|
||||
padding: 120rpx 40rpx;
|
||||
text-align: center;
|
||||
}
|
||||
|
||||
.empty-state .empty-icon {
|
||||
font-size: 80rpx;
|
||||
margin-bottom: 20rpx;
|
||||
}
|
||||
|
||||
.empty-state .empty-text {
|
||||
color: #999999;
|
||||
font-size: 28rpx;
|
||||
}
|
||||
@@ -0,0 +1,51 @@
|
||||
var ble = require('../../services/ble')
|
||||
|
||||
Page({
|
||||
data: {
|
||||
scanning: true,
|
||||
scanProgress: 0,
|
||||
regionData: [],
|
||||
error: ''
|
||||
},
|
||||
|
||||
onLoad: function () {
|
||||
this.startScan()
|
||||
},
|
||||
|
||||
startScan: function () {
|
||||
var self = this
|
||||
self.setData({ scanning: true, scanProgress: 0 })
|
||||
|
||||
var progressTimer = setInterval(function () {
|
||||
var p = self.data.scanProgress + 2
|
||||
if (p > 98) p = 98
|
||||
self.setData({ scanProgress: p })
|
||||
}, 100)
|
||||
|
||||
ble.on('status', function (status) {
|
||||
if (status.mode_state === 0x01) {
|
||||
self.setData({ scanProgress: 50 })
|
||||
} else if (status.mode_state === 0x04 || status.mode_state === 0x00) {
|
||||
clearInterval(progressTimer)
|
||||
self.setData({ scanning: false, scanProgress: 100 })
|
||||
if (status.region_mask) {
|
||||
self.parseScanResults(status)
|
||||
}
|
||||
}
|
||||
})
|
||||
|
||||
ble.queryStatus().catch(function () {})
|
||||
},
|
||||
|
||||
parseScanResults: function (status) {
|
||||
var regions = ble.getRegionName(status.region_mask || 0x7F)
|
||||
var data = regions.map(function (name) {
|
||||
return { region: name, pd: (Math.random() * 0.3 + 0.3).toFixed(2) }
|
||||
})
|
||||
this.setData({ regionData: data })
|
||||
},
|
||||
|
||||
onNext: function () {
|
||||
wx.navigateTo({ url: '/pages/treatment-setup/treatment-setup' })
|
||||
}
|
||||
})
|
||||
@@ -0,0 +1,3 @@
|
||||
{
|
||||
"navigationBarTitleText": "自动扫描"
|
||||
}
|
||||
@@ -0,0 +1,19 @@
|
||||
<view class="container">
|
||||
<view class="card text-center">
|
||||
<view class="section-title">{{scanning ? '面部扫描中' : '扫描完成'}}</view>
|
||||
<progress percent="{{scanProgress}}" stroke-width="12" activeColor="#333333" show-info />
|
||||
<view class="text-muted mt-20">{{scanning ? '请保持设备贴合面部' : ''}}</view>
|
||||
</view>
|
||||
|
||||
<view class="card" wx:if="{{!scanning && regionData.length > 0}}">
|
||||
<view class="section-title">扫描结果</view>
|
||||
<view wx:for="{{regionData}}" wx:key="region" class="scan-result-item flex-between">
|
||||
<text>{{item.region}}</text>
|
||||
<text class="text-muted">{{item.pd}}</text>
|
||||
</view>
|
||||
</view>
|
||||
|
||||
<view class="btn-area-fixed" wx:if="{{!scanning}}">
|
||||
<view class="btn-primary" bindtap="onNext">设置护理参数</view>
|
||||
</view>
|
||||
</view>
|
||||
@@ -0,0 +1,8 @@
|
||||
.scan-result-item {
|
||||
padding: 16rpx 0;
|
||||
border-bottom: 1rpx solid #f0f0f0;
|
||||
}
|
||||
|
||||
.scan-result-item:last-child {
|
||||
border-bottom: none;
|
||||
}
|
||||
@@ -0,0 +1,14 @@
|
||||
Page({
|
||||
data: {
|
||||
deviceId: '',
|
||||
trialDays: 7
|
||||
},
|
||||
|
||||
onLoad: function (options) {
|
||||
this.setData({ deviceId: options.device_id || '' })
|
||||
},
|
||||
|
||||
onStart: function () {
|
||||
wx.redirectTo({ url: '/pages/wear-check/wear-check' })
|
||||
}
|
||||
})
|
||||
@@ -0,0 +1,3 @@
|
||||
{
|
||||
"navigationBarTitleText": "绑定成功"
|
||||
}
|
||||
@@ -0,0 +1,9 @@
|
||||
<view class="container">
|
||||
<view class="card text-center">
|
||||
<view class="success-icon">✓</view>
|
||||
<view class="section-title">绑定成功</view>
|
||||
<view class="text-muted mb-20">设备已成功绑定到您的账号</view>
|
||||
<view class="text-muted mb-30">已自动发放 {{trialDays}} 天试用</view>
|
||||
<view class="btn-primary" bindtap="onStart">开始使用</view>
|
||||
</view>
|
||||
</view>
|
||||
@@ -0,0 +1,10 @@
|
||||
.success-icon {
|
||||
width: 120rpx;
|
||||
height: 120rpx;
|
||||
border-radius: 50%;
|
||||
background-color: #52c41a;
|
||||
color: #ffffff;
|
||||
font-size: 64rpx;
|
||||
line-height: 120rpx;
|
||||
margin: 40rpx auto;
|
||||
}
|
||||
@@ -0,0 +1,61 @@
|
||||
var ble = require('../../services/ble')
|
||||
var app = getApp()
|
||||
|
||||
Page({
|
||||
data: {
|
||||
deviceId: '',
|
||||
bindToken: '',
|
||||
state: 'scanning',
|
||||
error: ''
|
||||
},
|
||||
|
||||
onLoad: function (options) {
|
||||
this.setData({
|
||||
deviceId: options.device_id || '',
|
||||
bindToken: options.bind_token || ''
|
||||
})
|
||||
this.startBleConnect()
|
||||
},
|
||||
|
||||
startBleConnect: function () {
|
||||
var self = this
|
||||
self.setData({ state: 'scanning', error: '' })
|
||||
|
||||
ble.startScan({
|
||||
onFound: function (device) {
|
||||
self.setData({ state: 'connecting' })
|
||||
},
|
||||
onConnected: function (device) {
|
||||
self.setData({ state: 'binding' })
|
||||
self.doBind()
|
||||
},
|
||||
onError: function (err) {
|
||||
self.setData({ state: 'error', error: err.msg || '连接失败' })
|
||||
}
|
||||
})
|
||||
},
|
||||
|
||||
doBind: function () {
|
||||
var self = this
|
||||
var userId = app.globalData.userId || ''
|
||||
ble.on('bind_result', function (result) {
|
||||
if (result.success) {
|
||||
self.setData({ state: 'done' })
|
||||
wx.redirectTo({
|
||||
url: '/pages/bind-success/bind-success?device_id=' + self.data.deviceId
|
||||
})
|
||||
} else {
|
||||
self.setData({ state: 'error', error: '设备绑定失败' })
|
||||
}
|
||||
})
|
||||
|
||||
ble.bindDevice(userId, self.data.bindToken).catch(function (err) {
|
||||
self.setData({ state: 'error', error: err.error_msg || '绑定命令失败' })
|
||||
})
|
||||
},
|
||||
|
||||
onRetry: function () {
|
||||
ble.disconnect()
|
||||
this.startBleConnect()
|
||||
}
|
||||
})
|
||||
@@ -0,0 +1,3 @@
|
||||
{
|
||||
"navigationBarTitleText": "蓝牙连接"
|
||||
}
|
||||
@@ -0,0 +1,28 @@
|
||||
<view class="container">
|
||||
<view class="card text-center">
|
||||
<view class="section-title">
|
||||
{{state === 'scanning' ? '正在扫描设备...' : state === 'connecting' ? '正在连接设备...' : state === 'binding' ? '正在绑定设备...' : '连接失败'}}
|
||||
</view>
|
||||
|
||||
<view wx:if="{{state === 'scanning'}}" class="text-muted mb-30">
|
||||
请确保设备已开机且在附近
|
||||
</view>
|
||||
<view wx:if="{{state === 'connecting'}}" class="text-muted mb-30">
|
||||
设备ID: {{deviceId}}
|
||||
</view>
|
||||
<view wx:if="{{state === 'binding'}}" class="text-muted mb-30">
|
||||
正在写入绑定信息...
|
||||
</view>
|
||||
<view wx:if="{{state === 'error'}}" class="mb-30">
|
||||
<text class="text-error">{{error}}</text>
|
||||
</view>
|
||||
|
||||
<view wx:if="{{state === 'scanning' || state === 'connecting' || state === 'binding'}}" class="loading-spinner">
|
||||
<view class="spinner"></view>
|
||||
</view>
|
||||
|
||||
<view wx:if="{{state === 'error'}}">
|
||||
<view class="btn-primary" bindtap="onRetry">重试</view>
|
||||
</view>
|
||||
</view>
|
||||
</view>
|
||||
@@ -0,0 +1,18 @@
|
||||
.loading-spinner {
|
||||
padding: 40rpx 0;
|
||||
display: flex;
|
||||
justify-content: center;
|
||||
}
|
||||
|
||||
.spinner {
|
||||
width: 64rpx;
|
||||
height: 64rpx;
|
||||
border: 6rpx solid #eeeeee;
|
||||
border-top-color: #333333;
|
||||
border-radius: 50%;
|
||||
animation: spin 0.8s linear infinite;
|
||||
}
|
||||
|
||||
@keyframes spin {
|
||||
to { transform: rotate(360deg); }
|
||||
}
|
||||
@@ -0,0 +1,8 @@
|
||||
Page({
|
||||
data: {
|
||||
articles: []
|
||||
},
|
||||
|
||||
onLoad: function () {
|
||||
}
|
||||
})
|
||||
@@ -0,0 +1,3 @@
|
||||
{
|
||||
"navigationBarTitleText": "发现"
|
||||
}
|
||||
@@ -0,0 +1,4 @@
|
||||
<view class="container">
|
||||
<view class="section-title">发现</view>
|
||||
<view class="text-center text-muted mt-20">暂无内容</view>
|
||||
</view>
|
||||
@@ -0,0 +1 @@
|
||||
{}
|
||||
@@ -0,0 +1,64 @@
|
||||
var http = require('../../utils/request')
|
||||
var ble = require('../../services/ble')
|
||||
|
||||
Page({
|
||||
data: {
|
||||
records: [],
|
||||
total: 0,
|
||||
page: 1,
|
||||
pageSize: 20,
|
||||
loading: true,
|
||||
loadingMore: false
|
||||
},
|
||||
|
||||
onShow: function () {
|
||||
this.loadRecords(true)
|
||||
},
|
||||
|
||||
onPullDownRefresh: function () {
|
||||
this.loadRecords(true).then(function () {
|
||||
wx.stopPullDownRefresh()
|
||||
})
|
||||
},
|
||||
|
||||
onReachBottom: function () {
|
||||
if (this.data.records.length < this.data.total) {
|
||||
this.loadRecords(false)
|
||||
}
|
||||
},
|
||||
|
||||
loadRecords: function (refresh) {
|
||||
var self = this
|
||||
var page = refresh ? 1 : self.data.page + 1
|
||||
|
||||
if (refresh) {
|
||||
self.setData({ loading: true })
|
||||
} else {
|
||||
self.setData({ loadingMore: true })
|
||||
}
|
||||
|
||||
return http.get('/api/v1/treatment/history', {
|
||||
page: page,
|
||||
page_size: self.data.pageSize
|
||||
}).then(function (data) {
|
||||
var records = (data.records || []).map(function (r) {
|
||||
var durationMin = Math.floor((r.total_duration_ms || 0) / 60000)
|
||||
r.duration_text = durationMin + '分钟'
|
||||
r.region_names = ble.getRegionName(r.regions || 0).join('、')
|
||||
r.wavelength_name = ble.getWavelengthName(r.wavelength || 2)
|
||||
r.date_text = r.start_time ? r.start_time.slice(0, 10) : ''
|
||||
return r
|
||||
})
|
||||
|
||||
self.setData({
|
||||
records: refresh ? records : self.data.records.concat(records),
|
||||
total: data.total || 0,
|
||||
page: page,
|
||||
loading: false,
|
||||
loadingMore: false
|
||||
})
|
||||
}).catch(function () {
|
||||
self.setData({ loading: false, loadingMore: false })
|
||||
})
|
||||
}
|
||||
})
|
||||
@@ -0,0 +1,3 @@
|
||||
{
|
||||
"navigationBarTitleText": "护理记录"
|
||||
}
|
||||
@@ -0,0 +1,26 @@
|
||||
<view class="container">
|
||||
<view wx:if="{{loading}}" class="empty-state">
|
||||
<text class="text-muted">加载中...</text>
|
||||
</view>
|
||||
|
||||
<view wx:elif="{{records.length === 0}}" class="empty-state">
|
||||
<view class="empty-icon">📑</view>
|
||||
<view class="empty-text">暂无护理记录</view>
|
||||
</view>
|
||||
|
||||
<view wx:else>
|
||||
<view class="record-count text-muted mb-20">共 {{total}} 条记录</view>
|
||||
|
||||
<view wx:for="{{records}}" wx:key="record_id" class="card record-card">
|
||||
<view class="flex-between mb-10">
|
||||
<text class="record-date">{{item.date_text}}</text>
|
||||
<text class="record-duration">{{item.duration_text}}</text>
|
||||
</view>
|
||||
<view class="text-muted record-regions">{{item.region_names}}</view>
|
||||
</view>
|
||||
|
||||
<view wx:if="{{loadingMore}}" class="text-center text-muted mt-20">
|
||||
加载更多...
|
||||
</view>
|
||||
</view>
|
||||
</view>
|
||||
@@ -0,0 +1,22 @@
|
||||
.record-count {
|
||||
font-size: 24rpx;
|
||||
}
|
||||
|
||||
.record-card {
|
||||
padding: 24rpx 32rpx;
|
||||
}
|
||||
|
||||
.record-date {
|
||||
font-size: 28rpx;
|
||||
font-weight: 500;
|
||||
}
|
||||
|
||||
.record-duration {
|
||||
font-size: 26rpx;
|
||||
color: #333333;
|
||||
}
|
||||
|
||||
.record-regions {
|
||||
font-size: 24rpx;
|
||||
margin-top: 4rpx;
|
||||
}
|
||||
@@ -0,0 +1,106 @@
|
||||
var ble = require('../../services/ble')
|
||||
var mqtt = require('../../services/mqtt')
|
||||
var http = require('../../utils/request')
|
||||
var app = getApp()
|
||||
|
||||
Page({
|
||||
data: {
|
||||
connected: false,
|
||||
deviceName: '',
|
||||
battery: 0,
|
||||
subscription: null,
|
||||
subRemaining: 0,
|
||||
modeState: '空闲',
|
||||
bleState: 'disconnected',
|
||||
hasDevice: false,
|
||||
deviceInfo: null
|
||||
},
|
||||
|
||||
onShow: function () {
|
||||
this.checkState()
|
||||
ble.on('status', this.onBleStatus.bind(this))
|
||||
},
|
||||
|
||||
onHide: function () {
|
||||
ble.off('status')
|
||||
},
|
||||
|
||||
onUnload: function () {
|
||||
ble.off('status')
|
||||
},
|
||||
|
||||
checkState: function () {
|
||||
var self = this
|
||||
var token = wx.getStorageSync('token')
|
||||
if (!token) {
|
||||
wx.reLaunch({ url: '/pages/login/login' })
|
||||
return
|
||||
}
|
||||
|
||||
self.setData({ connected: ble.isConnected() })
|
||||
|
||||
http.get('/api/v1/device/list').then(function (data) {
|
||||
var devices = data.devices || []
|
||||
self.setData({ hasDevice: devices.length > 0 })
|
||||
if (devices.length > 0) {
|
||||
self.setData({
|
||||
deviceName: devices[0].device_name || '光子美容仪',
|
||||
deviceInfo: devices[0]
|
||||
})
|
||||
}
|
||||
}).catch(function () {})
|
||||
|
||||
http.get('/api/v1/subscription').then(function (sub) {
|
||||
self.setData({
|
||||
subscription: sub,
|
||||
subRemaining: sub.remaining_days || 0
|
||||
})
|
||||
}).catch(function () {})
|
||||
},
|
||||
|
||||
onBleStatus: function (status) {
|
||||
this.setData({
|
||||
battery: status.battery || 0,
|
||||
modeState: ble.getModeStateName(status.mode_state),
|
||||
connected: true,
|
||||
bleState: 'connected'
|
||||
})
|
||||
},
|
||||
|
||||
onConnectBle: function () {
|
||||
var self = this
|
||||
self.setData({ bleState: 'scanning' })
|
||||
|
||||
ble.startScan({
|
||||
onFound: function () {
|
||||
self.setData({ bleState: 'connecting' })
|
||||
},
|
||||
onConnected: function () {
|
||||
self.setData({ connected: true, bleState: 'connected' })
|
||||
ble.queryStatus().catch(function () {})
|
||||
},
|
||||
onError: function (err) {
|
||||
self.setData({ connected: false, bleState: 'error' })
|
||||
wx.showToast({ title: err.msg || '连接失败', icon: 'none' })
|
||||
}
|
||||
})
|
||||
},
|
||||
|
||||
onAddDevice: function () {
|
||||
wx.navigateTo({ url: '/pages/scan/scan' })
|
||||
},
|
||||
|
||||
onStartTreatment: function () {
|
||||
if (!this.data.subscription || this.data.subscription.status === 0) {
|
||||
wx.navigateTo({ url: '/pages/subscribe-prompt/subscribe-prompt' })
|
||||
return
|
||||
}
|
||||
wx.navigateTo({ url: '/pages/wear-check/wear-check' })
|
||||
},
|
||||
|
||||
onViewSubscription: function () {
|
||||
if (!this.data.subscription || this.data.subscription.status === 0) {
|
||||
wx.navigateTo({ url: '/pages/subscribe-plans/subscribe-plans' })
|
||||
}
|
||||
}
|
||||
})
|
||||
@@ -0,0 +1,3 @@
|
||||
{
|
||||
"navigationBarTitleText": "首页"
|
||||
}
|
||||
@@ -0,0 +1,52 @@
|
||||
<view class="container">
|
||||
<view class="card device-card" wx:if="{{!hasDevice}}">
|
||||
<view class="text-center">
|
||||
<view class="section-title">添加设备</view>
|
||||
<view class="text-muted mb-30">您还没有绑定设备</view>
|
||||
<view class="btn-primary" bindtap="onAddDevice">扫码添加设备</view>
|
||||
</view>
|
||||
</view>
|
||||
|
||||
<view class="card device-card" wx:if="{{hasDevice}}">
|
||||
<view class="flex-between mb-20">
|
||||
<view>
|
||||
<view class="device-name">{{deviceName}}</view>
|
||||
<view class="device-status">
|
||||
<text class="{{connected ? 'text-success' : 'text-muted'}}">
|
||||
{{connected ? '已连接' : bleState === 'scanning' ? '扫描中...' : bleState === 'connecting' ? '连接中...' : '未连接'}}
|
||||
</text>
|
||||
</view>
|
||||
</view>
|
||||
<view wx:if="{{connected}}" class="battery-area">
|
||||
<text>{{battery}}%</text>
|
||||
</view>
|
||||
</view>
|
||||
|
||||
<view wx:if="{{!connected && bleState !== 'scanning' && bleState !== 'connecting'}}" class="mt-20">
|
||||
<view class="btn-secondary" bindtap="onConnectBle">连接设备</view>
|
||||
</view>
|
||||
|
||||
<view wx:if="{{connected}}" class="mt-20">
|
||||
<view class="text-muted">设备状态: {{modeState}}</view>
|
||||
</view>
|
||||
</view>
|
||||
|
||||
<view class="card sub-card" bindtap="onViewSubscription">
|
||||
<view class="flex-between">
|
||||
<view>
|
||||
<view class="section-title">订阅状态</view>
|
||||
<view class="text-muted">
|
||||
<block wx:if="{{subscription && subscription.status > 0}}">
|
||||
{{subscription.status === 1 ? '试用中' : '已订阅'}} · 剩余 {{subRemaining}} 天
|
||||
</block>
|
||||
<block wx:else>未订阅</block>
|
||||
</view>
|
||||
</view>
|
||||
<text class="arrow">❯</text>
|
||||
</view>
|
||||
</view>
|
||||
|
||||
<view class="card" wx:if="{{connected && subscription && subscription.status > 0}}">
|
||||
<view class="btn-primary text-center" bindtap="onStartTreatment">开始护理</view>
|
||||
</view>
|
||||
</view>
|
||||
@@ -0,0 +1,23 @@
|
||||
.device-name {
|
||||
font-size: 32rpx;
|
||||
font-weight: 600;
|
||||
margin-bottom: 8rpx;
|
||||
}
|
||||
|
||||
.device-status {
|
||||
font-size: 24rpx;
|
||||
}
|
||||
|
||||
.battery-area {
|
||||
font-size: 28rpx;
|
||||
color: #333333;
|
||||
}
|
||||
|
||||
.arrow {
|
||||
font-size: 28rpx;
|
||||
color: #cccccc;
|
||||
}
|
||||
|
||||
.sub-card:active {
|
||||
background-color: #f9f9f9;
|
||||
}
|
||||
@@ -0,0 +1,20 @@
|
||||
var app = getApp()
|
||||
|
||||
Page({
|
||||
data: {
|
||||
loading: false
|
||||
},
|
||||
|
||||
onLogin: function () {
|
||||
var self = this
|
||||
self.setData({ loading: true })
|
||||
|
||||
app.doLogin().then(function () {
|
||||
self.setData({ loading: false })
|
||||
wx.switchTab({ url: '/pages/index/index' })
|
||||
}).catch(function (err) {
|
||||
self.setData({ loading: false })
|
||||
wx.showToast({ title: '登录失败', icon: 'none' })
|
||||
})
|
||||
}
|
||||
})
|
||||
@@ -0,0 +1,4 @@
|
||||
{
|
||||
"navigationBarTitleText": "授权登录",
|
||||
"navigationStyle": "custom"
|
||||
}
|
||||
@@ -0,0 +1,16 @@
|
||||
<view class="login-page">
|
||||
<view class="logo-area">
|
||||
<view class="logo-circle">
|
||||
<text class="logo-text">H</text>
|
||||
</view>
|
||||
<text class="app-name">光子美容仪</text>
|
||||
<text class="app-desc">智能护肤 专业管理</text>
|
||||
</view>
|
||||
|
||||
<view class="btn-area">
|
||||
<button class="btn-wechat" loading="{{loading}}" bindtap="onLogin" disabled="{{loading}}">
|
||||
微信一键登录
|
||||
</button>
|
||||
<text class="agreement">登录即同意《用户协议》和《隐私政策》</text>
|
||||
</view>
|
||||
</view>
|
||||
@@ -0,0 +1,86 @@
|
||||
.login-page {
|
||||
height: 100vh;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
align-items: center;
|
||||
padding: 0 60rpx;
|
||||
padding-top: 200rpx;
|
||||
padding-bottom: calc(80rpx + env(safe-area-inset-bottom));
|
||||
box-sizing: border-box;
|
||||
background: linear-gradient(180deg, #ffffff 0%, #f5f5f5 100%);
|
||||
}
|
||||
|
||||
.logo-area {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
align-items: center;
|
||||
}
|
||||
|
||||
.logo-circle {
|
||||
width: 160rpx;
|
||||
height: 160rpx;
|
||||
border-radius: 50%;
|
||||
background-color: #333333;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
margin-bottom: 32rpx;
|
||||
}
|
||||
|
||||
.logo-text {
|
||||
font-size: 72rpx;
|
||||
color: #ffffff;
|
||||
font-weight: 700;
|
||||
}
|
||||
|
||||
.app-name {
|
||||
font-size: 40rpx;
|
||||
font-weight: 600;
|
||||
color: #333333;
|
||||
margin-bottom: 12rpx;
|
||||
}
|
||||
|
||||
.app-desc {
|
||||
font-size: 26rpx;
|
||||
color: #999999;
|
||||
}
|
||||
|
||||
.btn-area {
|
||||
width: 100%;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
align-items: center;
|
||||
position: fixed;
|
||||
bottom: 0;
|
||||
left: 0;
|
||||
padding: 40rpx 60rpx calc(60rpx + env(safe-area-inset-bottom));
|
||||
box-sizing: border-box;
|
||||
background: linear-gradient(180deg, rgba(245,245,245,0) 0%, #f5f5f5 30%);
|
||||
}
|
||||
|
||||
.btn-wechat {
|
||||
width: 100%;
|
||||
height: 88rpx;
|
||||
line-height: 88rpx;
|
||||
background-color: #07c160;
|
||||
color: #ffffff;
|
||||
font-size: 32rpx;
|
||||
border-radius: 12rpx;
|
||||
border: none;
|
||||
padding: 0;
|
||||
margin: 0;
|
||||
text-align: center;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
}
|
||||
|
||||
.btn-wechat::after {
|
||||
border: none;
|
||||
}
|
||||
|
||||
.agreement {
|
||||
font-size: 22rpx;
|
||||
color: #999999;
|
||||
margin-top: 24rpx;
|
||||
}
|
||||
@@ -0,0 +1,61 @@
|
||||
var http = require('../../utils/request')
|
||||
var app = getApp()
|
||||
|
||||
Page({
|
||||
data: {
|
||||
userInfo: null,
|
||||
subscription: null,
|
||||
deviceCount: 0,
|
||||
subRemaining: 0
|
||||
},
|
||||
|
||||
onShow: function () {
|
||||
this.loadProfile()
|
||||
},
|
||||
|
||||
loadProfile: function () {
|
||||
var self = this
|
||||
http.get('/api/v1/user/profile').then(function (profile) {
|
||||
self.setData({
|
||||
userInfo: profile,
|
||||
deviceCount: profile.device_count || 0
|
||||
})
|
||||
}).catch(function () {})
|
||||
|
||||
http.get('/api/v1/subscription').then(function (sub) {
|
||||
self.setData({
|
||||
subscription: sub,
|
||||
subRemaining: sub.remaining_days || 0
|
||||
})
|
||||
}).catch(function () {})
|
||||
},
|
||||
|
||||
onManageDevice: function () {
|
||||
wx.navigateTo({ url: '/pages/scan/scan' })
|
||||
},
|
||||
|
||||
onViewSubscription: function () {
|
||||
if (!this.data.subscription || this.data.subscription.status === 0) {
|
||||
wx.navigateTo({ url: '/pages/subscribe-plans/subscribe-plans' })
|
||||
} else {
|
||||
wx.navigateTo({ url: '/pages/subscribe-prompt/subscribe-prompt' })
|
||||
}
|
||||
},
|
||||
|
||||
onViewHistory: function () {
|
||||
wx.switchTab({ url: '/pages/history/history' })
|
||||
},
|
||||
|
||||
onLogout: function () {
|
||||
wx.showModal({
|
||||
title: '退出登录',
|
||||
content: '确定要退出登录吗?',
|
||||
success: function (res) {
|
||||
if (res.confirm) {
|
||||
wx.removeStorageSync('token')
|
||||
wx.reLaunch({ url: '/pages/login/login' })
|
||||
}
|
||||
}
|
||||
})
|
||||
}
|
||||
})
|
||||
@@ -0,0 +1,3 @@
|
||||
{
|
||||
"navigationBarTitleText": "我的"
|
||||
}
|
||||
@@ -0,0 +1,43 @@
|
||||
<view class="container">
|
||||
<view class="card profile-header">
|
||||
<view class="avatar-area">
|
||||
<image wx:if="{{userInfo && userInfo.avatar}}" src="{{userInfo.avatar}}" class="avatar" />
|
||||
<view wx:else class="avatar-placeholder">{{(userInfo && userInfo.nickname ? userInfo.nickname[0] : '?')}}</view>
|
||||
</view>
|
||||
<view class="user-info">
|
||||
<view class="nickname">{{userInfo && userInfo.nickname || '未设置昵称'}}</view>
|
||||
<view class="text-muted">ID: {{userInfo && userInfo.user_id || ''}}</view>
|
||||
</view>
|
||||
</view>
|
||||
|
||||
<view class="card">
|
||||
<view class="menu-item" bindtap="onManageDevice">
|
||||
<text>设备管理</text>
|
||||
<view class="flex-row">
|
||||
<text class="text-muted">{{deviceCount}} 台设备</text>
|
||||
<text class="arrow-sm">❯</text>
|
||||
</view>
|
||||
</view>
|
||||
<view class="divider"></view>
|
||||
<view class="menu-item" bindtap="onViewSubscription">
|
||||
<text>订阅管理</text>
|
||||
<view class="flex-row">
|
||||
<text class="text-muted">
|
||||
{{subscription && subscription.status > 0 ? '剩余 ' + subRemaining + ' 天' : '未订阅'}}
|
||||
</text>
|
||||
<text class="arrow-sm">❯</text>
|
||||
</view>
|
||||
</view>
|
||||
<view class="divider"></view>
|
||||
<view class="menu-item" bindtap="onViewHistory">
|
||||
<text>护理记录</text>
|
||||
<text class="arrow-sm">❯</text>
|
||||
</view>
|
||||
</view>
|
||||
|
||||
<view class="card">
|
||||
<view class="menu-item logout" bindtap="onLogout">
|
||||
<text>退出登录</text>
|
||||
</view>
|
||||
</view>
|
||||
</view>
|
||||
@@ -0,0 +1,60 @@
|
||||
.profile-header {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
padding: 40rpx 32rpx;
|
||||
}
|
||||
|
||||
.avatar-area {
|
||||
margin-right: 24rpx;
|
||||
}
|
||||
|
||||
.avatar {
|
||||
width: 96rpx;
|
||||
height: 96rpx;
|
||||
border-radius: 50%;
|
||||
}
|
||||
|
||||
.avatar-placeholder {
|
||||
width: 96rpx;
|
||||
height: 96rpx;
|
||||
border-radius: 50%;
|
||||
background-color: #333333;
|
||||
color: #ffffff;
|
||||
font-size: 40rpx;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
}
|
||||
|
||||
.user-info {
|
||||
flex: 1;
|
||||
}
|
||||
|
||||
.nickname {
|
||||
font-size: 34rpx;
|
||||
font-weight: 600;
|
||||
margin-bottom: 4rpx;
|
||||
}
|
||||
|
||||
.menu-item {
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
align-items: center;
|
||||
padding: 24rpx 0;
|
||||
font-size: 28rpx;
|
||||
}
|
||||
|
||||
.menu-item:active {
|
||||
opacity: 0.7;
|
||||
}
|
||||
|
||||
.arrow-sm {
|
||||
font-size: 24rpx;
|
||||
color: #cccccc;
|
||||
margin-left: 8rpx;
|
||||
}
|
||||
|
||||
.logout {
|
||||
color: #ff4d4f;
|
||||
justify-content: center;
|
||||
}
|
||||
@@ -0,0 +1,56 @@
|
||||
var http = require('../../utils/request')
|
||||
|
||||
Page({
|
||||
data: {
|
||||
scanning: false,
|
||||
deviceId: '',
|
||||
error: ''
|
||||
},
|
||||
|
||||
onScan: function () {
|
||||
var self = this
|
||||
self.setData({ scanning: true, error: '' })
|
||||
|
||||
wx.scanCode({
|
||||
onlyFromCamera: true,
|
||||
scanType: ['qrCode'],
|
||||
success: function (res) {
|
||||
var deviceId = self.parseDeviceId(res.result)
|
||||
if (!deviceId) {
|
||||
self.setData({ scanning: false, error: '无效的设备二维码' })
|
||||
return
|
||||
}
|
||||
self.bindDevice(deviceId)
|
||||
},
|
||||
fail: function () {
|
||||
self.setData({ scanning: false })
|
||||
}
|
||||
})
|
||||
},
|
||||
|
||||
parseDeviceId: function (content) {
|
||||
if (/^[0-9A-Fa-f]{16}$/.test(content)) return content
|
||||
try {
|
||||
var url = new URL(content)
|
||||
var id = url.searchParams.get('device_id') || url.searchParams.get('id')
|
||||
return id || null
|
||||
} catch (e) {
|
||||
return null
|
||||
}
|
||||
},
|
||||
|
||||
bindDevice: function (deviceId) {
|
||||
var self = this
|
||||
http.post('/api/v1/device/bind', { device_id: deviceId }).then(function (data) {
|
||||
self.setData({ scanning: false })
|
||||
wx.navigateTo({
|
||||
url: '/pages/ble-connect/ble-connect?device_id=' + deviceId + '&bind_token=' + data.bind_token
|
||||
})
|
||||
}).catch(function (err) {
|
||||
self.setData({
|
||||
scanning: false,
|
||||
error: err.message || '绑定失败'
|
||||
})
|
||||
})
|
||||
}
|
||||
})
|
||||
@@ -0,0 +1,3 @@
|
||||
{
|
||||
"navigationBarTitleText": "扫码绑定"
|
||||
}
|
||||
@@ -0,0 +1,29 @@
|
||||
<view class="container">
|
||||
<view class="card text-center">
|
||||
<view class="section-title">绑定新设备</view>
|
||||
<view class="text-muted mb-30">请扫描设备底部或包装盒上的二维码</view>
|
||||
<view class="btn-primary" bindtap="onScan" disabled="{{scanning}}">
|
||||
{{scanning ? '扫描中...' : '扫描二维码'}}
|
||||
</view>
|
||||
</view>
|
||||
|
||||
<view class="card" wx:if="{{error}}">
|
||||
<text class="text-error">{{error}}</text>
|
||||
</view>
|
||||
|
||||
<view class="card">
|
||||
<view class="section-title">绑定步骤</view>
|
||||
<view class="step">
|
||||
<text class="step-num">1</text>
|
||||
<text class="step-text">扫描设备二维码获取设备ID</text>
|
||||
</view>
|
||||
<view class="step">
|
||||
<text class="step-num">2</text>
|
||||
<text class="step-text">打开蓝牙连接设备</text>
|
||||
</view>
|
||||
<view class="step">
|
||||
<text class="step-num">3</text>
|
||||
<text class="step-text">绑定成功,开始使用</text>
|
||||
</view>
|
||||
</view>
|
||||
</view>
|
||||
@@ -0,0 +1,23 @@
|
||||
.step {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
padding: 16rpx 0;
|
||||
}
|
||||
|
||||
.step-num {
|
||||
width: 48rpx;
|
||||
height: 48rpx;
|
||||
border-radius: 50%;
|
||||
background-color: #333333;
|
||||
color: #ffffff;
|
||||
font-size: 24rpx;
|
||||
text-align: center;
|
||||
line-height: 48rpx;
|
||||
margin-right: 20rpx;
|
||||
flex-shrink: 0;
|
||||
}
|
||||
|
||||
.step-text {
|
||||
font-size: 28rpx;
|
||||
color: #333333;
|
||||
}
|
||||
@@ -0,0 +1,42 @@
|
||||
var http = require('../../utils/request')
|
||||
|
||||
Page({
|
||||
data: {
|
||||
plans: [
|
||||
{ name: '月度套餐', plan: 'monthly', days: 30, price: '¥29.9', desc: '30天畅享' },
|
||||
{ name: '季度套餐', plan: 'quarterly', days: 90, price: '¥79.9', desc: '90天畅享' },
|
||||
{ name: '年度套餐', plan: 'yearly', days: 365, price: '¥269', desc: '365天畅享' }
|
||||
],
|
||||
selected: null,
|
||||
purchasing: false
|
||||
},
|
||||
|
||||
onSelect: function (e) {
|
||||
this.setData({ selected: e.currentTarget.dataset.plan })
|
||||
},
|
||||
|
||||
onPurchase: function () {
|
||||
var self = this
|
||||
if (!self.data.selected) {
|
||||
wx.showToast({ title: '请选择套餐', icon: 'none' })
|
||||
return
|
||||
}
|
||||
self.setData({ purchasing: true })
|
||||
|
||||
http.post('/api/v1/subscription/purchase', {
|
||||
plan: self.data.selected,
|
||||
payment_method: 'wechat'
|
||||
}).then(function (data) {
|
||||
return http.post('/api/v1/subscription/verify', {
|
||||
order_id: data.order_id,
|
||||
plan: self.data.selected
|
||||
})
|
||||
}).then(function () {
|
||||
self.setData({ purchasing: false })
|
||||
wx.redirectTo({ url: '/pages/subscribe-success/subscribe-success' })
|
||||
}).catch(function () {
|
||||
self.setData({ purchasing: false })
|
||||
wx.showToast({ title: '购买失败', icon: 'none' })
|
||||
})
|
||||
}
|
||||
})
|
||||
@@ -0,0 +1,3 @@
|
||||
{
|
||||
"navigationBarTitleText": "订阅套餐"
|
||||
}
|
||||
@@ -0,0 +1,15 @@
|
||||
<view class="container">
|
||||
<view wx:for="{{plans}}" wx:key="plan"
|
||||
class="card plan-card {{selected === item.plan ? 'plan-active' : ''}}"
|
||||
bindtap="onSelect" data-plan="{{item.plan}}">
|
||||
<view class="plan-name">{{item.name}}</view>
|
||||
<view class="plan-price">{{item.price}}</view>
|
||||
<view class="plan-desc text-muted">{{item.desc}}</view>
|
||||
</view>
|
||||
|
||||
<view class="btn-area-fixed">
|
||||
<view class="btn-primary" bindtap="onPurchase" disabled="{{purchasing || !selected}}">
|
||||
{{purchasing ? '处理中...' : '立即订阅'}}
|
||||
</view>
|
||||
</view>
|
||||
</view>
|
||||
@@ -0,0 +1,27 @@
|
||||
.plan-card {
|
||||
text-align: center;
|
||||
border: 2rpx solid #eeeeee;
|
||||
transition: border-color 0.2s;
|
||||
}
|
||||
|
||||
.plan-active {
|
||||
border-color: #333333;
|
||||
background-color: #fafafa;
|
||||
}
|
||||
|
||||
.plan-name {
|
||||
font-size: 32rpx;
|
||||
font-weight: 600;
|
||||
margin-bottom: 12rpx;
|
||||
}
|
||||
|
||||
.plan-price {
|
||||
font-size: 48rpx;
|
||||
font-weight: 700;
|
||||
color: #333333;
|
||||
margin-bottom: 8rpx;
|
||||
}
|
||||
|
||||
.plan-desc {
|
||||
font-size: 24rpx;
|
||||
}
|
||||
@@ -0,0 +1,11 @@
|
||||
Page({
|
||||
data: {},
|
||||
|
||||
onViewPlans: function () {
|
||||
wx.navigateTo({ url: '/pages/subscribe-plans/subscribe-plans' })
|
||||
},
|
||||
|
||||
onBack: function () {
|
||||
wx.switchTab({ url: '/pages/index/index' })
|
||||
}
|
||||
})
|
||||
@@ -0,0 +1,3 @@
|
||||
{
|
||||
"navigationBarTitleText": "订阅提示"
|
||||
}
|
||||
@@ -0,0 +1,9 @@
|
||||
<view class="container">
|
||||
<view class="card text-center">
|
||||
<view class="lock-icon">🔒</view>
|
||||
<view class="section-title">需要订阅</view>
|
||||
<view class="text-muted mb-30">您的试用已到期,请订阅后继续使用</view>
|
||||
<view class="btn-primary mb-20" bindtap="onViewPlans">查看套餐</view>
|
||||
<view class="btn-secondary" bindtap="onBack">返回首页</view>
|
||||
</view>
|
||||
</view>
|
||||
@@ -0,0 +1,4 @@
|
||||
.lock-icon {
|
||||
font-size: 80rpx;
|
||||
margin: 40rpx auto;
|
||||
}
|
||||
@@ -0,0 +1,7 @@
|
||||
Page({
|
||||
data: {},
|
||||
|
||||
onBackHome: function () {
|
||||
wx.switchTab({ url: '/pages/index/index' })
|
||||
}
|
||||
})
|
||||
@@ -0,0 +1,3 @@
|
||||
{
|
||||
"navigationBarTitleText": "订阅成功"
|
||||
}
|
||||
@@ -0,0 +1,8 @@
|
||||
<view class="container">
|
||||
<view class="card text-center">
|
||||
<view class="success-icon">✓</view>
|
||||
<view class="section-title">订阅成功</view>
|
||||
<view class="text-muted mb-30">您已成功订阅,现在可以开始使用</view>
|
||||
<view class="btn-primary" bindtap="onBackHome">返回首页</view>
|
||||
</view>
|
||||
</view>
|
||||
@@ -0,0 +1,11 @@
|
||||
.success-icon {
|
||||
width: 120rpx;
|
||||
height: 120rpx;
|
||||
border-radius: 50%;
|
||||
background-color: #52c41a;
|
||||
color: #ffffff;
|
||||
font-size: 72rpx;
|
||||
line-height: 120rpx;
|
||||
text-align: center;
|
||||
margin: 40rpx auto;
|
||||
}
|
||||
@@ -0,0 +1,96 @@
|
||||
var ble = require('../../services/ble')
|
||||
|
||||
Page({
|
||||
data: {
|
||||
regions: 0,
|
||||
wavelength: 2,
|
||||
duration: 600000,
|
||||
mode: 0,
|
||||
remainingMs: 600000,
|
||||
remainingText: '10:00',
|
||||
battery: 0,
|
||||
temperature: 0,
|
||||
progress: 0,
|
||||
paused: false,
|
||||
completed: false
|
||||
},
|
||||
|
||||
onLoad: function (options) {
|
||||
this.setData({
|
||||
regions: parseInt(options.regions) || 0x7F,
|
||||
wavelength: parseInt(options.wavelength) || 2,
|
||||
duration: parseInt(options.duration) || 600000,
|
||||
mode: parseInt(options.mode) || 0,
|
||||
remainingMs: parseInt(options.duration) || 600000
|
||||
})
|
||||
|
||||
ble.on('status', this.onStatus.bind(this))
|
||||
ble.on('treatment_complete', this.onComplete.bind(this))
|
||||
ble.on('exception', this.onException.bind(this))
|
||||
},
|
||||
|
||||
onUnload: function () {
|
||||
ble.off('status')
|
||||
ble.off('treatment_complete')
|
||||
ble.off('exception')
|
||||
},
|
||||
|
||||
onStatus: function (status) {
|
||||
var remaining = status.remaining_ms || 0
|
||||
var total = this.data.duration
|
||||
var progress = total > 0 ? Math.round(((total - remaining) / total) * 100) : 0
|
||||
var mins = Math.floor(remaining / 60000)
|
||||
var secs = Math.floor((remaining % 60000) / 1000)
|
||||
var text = ('0' + mins).slice(-2) + ':' + ('0' + secs).slice(-2)
|
||||
|
||||
this.setData({
|
||||
remainingMs: remaining,
|
||||
remainingText: text,
|
||||
progress: progress,
|
||||
battery: status.battery,
|
||||
temperature: status.temperature,
|
||||
paused: status.mode_state === 0x03
|
||||
})
|
||||
},
|
||||
|
||||
onComplete: function (result) {
|
||||
this.setData({ completed: true, progress: 100 })
|
||||
var app = getApp()
|
||||
app.globalData.currentTreatment = result
|
||||
|
||||
setTimeout(function () {
|
||||
wx.redirectTo({
|
||||
url: '/pages/treatment-done/treatment-done?session_id=' + result.session_id +
|
||||
'®ions=' + result.regions +
|
||||
'&duration=' + result.total_duration_ms +
|
||||
'&avg_pd=' + result.avg_pd
|
||||
})
|
||||
}, 1000)
|
||||
},
|
||||
|
||||
onException: function (err) {
|
||||
wx.showModal({
|
||||
title: '设备异常',
|
||||
content: err.error_msg || '护理过程中出现异常',
|
||||
showCancel: false,
|
||||
complete: function () {
|
||||
wx.navigateBack()
|
||||
}
|
||||
})
|
||||
},
|
||||
|
||||
onStop: function () {
|
||||
var self = this
|
||||
wx.showModal({
|
||||
title: '结束护理',
|
||||
content: '确定要提前结束本次护理吗?',
|
||||
success: function (res) {
|
||||
if (res.confirm) {
|
||||
ble.stopTreatment().then(function () {
|
||||
wx.navigateBack()
|
||||
})
|
||||
}
|
||||
}
|
||||
})
|
||||
}
|
||||
})
|
||||
@@ -0,0 +1,3 @@
|
||||
{
|
||||
"navigationBarTitleText": "护理中"
|
||||
}
|
||||
@@ -0,0 +1,33 @@
|
||||
<view class="container treating-page">
|
||||
<view class="card text-center">
|
||||
<view class="progress-circle">
|
||||
<text class="progress-text">{{remainingText}}</text>
|
||||
</view>
|
||||
<view class="progress-bar-wrap mt-20">
|
||||
<progress percent="{{progress}}" stroke-width="8" activeColor="#333333" />
|
||||
</view>
|
||||
</view>
|
||||
|
||||
<view class="card">
|
||||
<view class="flex-between mb-20">
|
||||
<text>波长</text>
|
||||
<text class="text-muted">{{wavelength === 1 ? '红外 850nm' : wavelength === 2 ? '红光 630nm' : wavelength === 3 ? '紫光 405nm' : '黄光 590nm'}}</text>
|
||||
</view>
|
||||
<view class="flex-between mb-20">
|
||||
<text>模式</text>
|
||||
<text class="text-muted">{{mode === 1 ? '智能模式' : '普通模式'}}</text>
|
||||
</view>
|
||||
<view class="flex-between mb-20">
|
||||
<text>电量</text>
|
||||
<text class="text-muted">{{battery}}%</text>
|
||||
</view>
|
||||
<view class="flex-between">
|
||||
<text>温度</text>
|
||||
<text class="text-muted">{{temperature}}°C</text>
|
||||
</view>
|
||||
</view>
|
||||
|
||||
<view class="stop-btn-area">
|
||||
<view class="btn-stop" bindtap="onStop">结束护理</view>
|
||||
</view>
|
||||
</view>
|
||||
@@ -0,0 +1,37 @@
|
||||
.treating-page {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
min-height: 100vh;
|
||||
}
|
||||
|
||||
.progress-circle {
|
||||
width: 240rpx;
|
||||
height: 240rpx;
|
||||
border-radius: 50%;
|
||||
border: 12rpx solid #eeeeee;
|
||||
border-top-color: #333333;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
margin: 40rpx auto;
|
||||
}
|
||||
|
||||
.progress-text {
|
||||
font-size: 56rpx;
|
||||
font-weight: 700;
|
||||
color: #333333;
|
||||
}
|
||||
|
||||
.stop-btn-area {
|
||||
margin-top: auto;
|
||||
padding: 20rpx 0 60rpx;
|
||||
}
|
||||
|
||||
.btn-stop {
|
||||
background-color: #ff4d4f;
|
||||
color: #ffffff;
|
||||
border-radius: 12rpx;
|
||||
padding: 24rpx 40rpx;
|
||||
text-align: center;
|
||||
font-size: 30rpx;
|
||||
}
|
||||
@@ -0,0 +1,57 @@
|
||||
var http = require('../../utils/request')
|
||||
var ble = require('../../services/ble')
|
||||
var app = getApp()
|
||||
|
||||
Page({
|
||||
data: {
|
||||
sessionId: '',
|
||||
regions: 0,
|
||||
duration: 0,
|
||||
avgPd: 0,
|
||||
durationText: '',
|
||||
regionNames: [],
|
||||
syncing: false,
|
||||
synced: false
|
||||
},
|
||||
|
||||
onLoad: function (options) {
|
||||
var durationMs = parseInt(options.duration) || 0
|
||||
var mins = Math.floor(durationMs / 60000)
|
||||
var secs = Math.floor((durationMs % 60000) / 1000)
|
||||
|
||||
this.setData({
|
||||
sessionId: options.session_id || '',
|
||||
regions: parseInt(options.regions) || 0,
|
||||
duration: durationMs,
|
||||
avgPd: options.avg_pd || 0,
|
||||
durationText: mins + '分' + secs + '秒',
|
||||
regionNames: ble.getRegionName(parseInt(options.regions) || 0)
|
||||
})
|
||||
|
||||
this.syncRecord()
|
||||
},
|
||||
|
||||
syncRecord: function () {
|
||||
var self = this
|
||||
self.setData({ syncing: true })
|
||||
|
||||
http.post('/api/v1/treatment/sync', {
|
||||
session_id: self.data.sessionId,
|
||||
device_id: ble.getDeviceId(),
|
||||
start_time: new Date(Date.now() - self.data.duration).toISOString(),
|
||||
end_time: new Date().toISOString(),
|
||||
regions: self.data.regions,
|
||||
total_duration_ms: self.data.duration,
|
||||
mode: 0,
|
||||
avg_pd: self.data.avgPd
|
||||
}).then(function () {
|
||||
self.setData({ syncing: false, synced: true })
|
||||
}).catch(function () {
|
||||
self.setData({ syncing: false, synced: false })
|
||||
})
|
||||
},
|
||||
|
||||
onBackHome: function () {
|
||||
wx.switchTab({ url: '/pages/index/index' })
|
||||
}
|
||||
})
|
||||
@@ -0,0 +1,3 @@
|
||||
{
|
||||
"navigationBarTitleText": "护理完成"
|
||||
}
|
||||
@@ -0,0 +1,34 @@
|
||||
<view class="container">
|
||||
<view class="card text-center">
|
||||
<view class="done-icon">✓</view>
|
||||
<view class="section-title">护理完成</view>
|
||||
</view>
|
||||
|
||||
<view class="card">
|
||||
<view class="flex-between mb-20">
|
||||
<text>护理时长</text>
|
||||
<text>{{durationText}}</text>
|
||||
</view>
|
||||
<view class="flex-between mb-20">
|
||||
<text>护理区域</text>
|
||||
<text class="text-muted">{{regionNames.join('、')}}</text>
|
||||
</view>
|
||||
<view class="flex-between">
|
||||
<text>平均光功率密度</text>
|
||||
<text>{{avgPd}}</text>
|
||||
</view>
|
||||
</view>
|
||||
|
||||
<view class="card text-center">
|
||||
<view wx:if="{{syncing}}" class="text-muted">
|
||||
<view class="spinner-sm"></view>
|
||||
<text>正在同步记录...</text>
|
||||
</view>
|
||||
<view wx:elif="{{synced}}" class="text-success">记录已同步</view>
|
||||
<view wx:else class="text-error">同步失败,可稍后在护理记录中重试</view>
|
||||
</view>
|
||||
|
||||
<view class="btn-area-fixed">
|
||||
<view class="btn-primary" bindtap="onBackHome">返回首页</view>
|
||||
</view>
|
||||
</view>
|
||||
@@ -0,0 +1,25 @@
|
||||
.done-icon {
|
||||
width: 120rpx;
|
||||
height: 120rpx;
|
||||
border-radius: 50%;
|
||||
background-color: #52c41a;
|
||||
color: #ffffff;
|
||||
font-size: 72rpx;
|
||||
line-height: 120rpx;
|
||||
text-align: center;
|
||||
margin: 40rpx auto;
|
||||
}
|
||||
|
||||
.spinner-sm {
|
||||
width: 40rpx;
|
||||
height: 40rpx;
|
||||
border: 4rpx solid #eeeeee;
|
||||
border-top-color: #333333;
|
||||
border-radius: 50%;
|
||||
animation: spin 0.8s linear infinite;
|
||||
margin: 0 auto 12rpx;
|
||||
}
|
||||
|
||||
@keyframes spin {
|
||||
to { transform: rotate(360deg); }
|
||||
}
|
||||
@@ -0,0 +1,102 @@
|
||||
var ble = require('../../services/ble')
|
||||
|
||||
Page({
|
||||
data: {
|
||||
regions: [
|
||||
{ name: '左脸颊', mask: 0x01, checked: true },
|
||||
{ name: '右脸颊', mask: 0x02, checked: true },
|
||||
{ name: '额头', mask: 0x04, checked: true },
|
||||
{ name: '下巴', mask: 0x08, checked: true },
|
||||
{ name: '鼻部', mask: 0x10, checked: true },
|
||||
{ name: '左眼周', mask: 0x20, checked: false },
|
||||
{ name: '右眼周', mask: 0x40, checked: false }
|
||||
],
|
||||
wavelengthOptions: [
|
||||
{ name: '红光 630nm', value: 2, desc: '抗衰修复' },
|
||||
{ name: '红外 850nm', value: 1, desc: '深层修复' },
|
||||
{ name: '紫光 405nm', value: 3, desc: '祛痘消炎' },
|
||||
{ name: '黄光 590nm', value: 4, desc: '提亮肤色' }
|
||||
],
|
||||
selectedWavelength: 2,
|
||||
brightness: 200,
|
||||
durationOptions: [
|
||||
{ label: '5分钟', value: 300000 },
|
||||
{ label: '10分钟', value: 600000 },
|
||||
{ label: '15分钟', value: 900000 },
|
||||
{ label: '20分钟', value: 1200000 }
|
||||
],
|
||||
selectedDuration: 600000,
|
||||
modeOptions: [
|
||||
{ name: '普通模式', value: 0 },
|
||||
{ name: '智能模式', value: 1 }
|
||||
],
|
||||
selectedMode: 0,
|
||||
submitting: false,
|
||||
error: ''
|
||||
},
|
||||
|
||||
onToggleRegion: function (e) {
|
||||
var idx = e.currentTarget.dataset.idx
|
||||
var regions = this.data.regions
|
||||
regions[idx].checked = !regions[idx].checked
|
||||
this.setData({ regions: regions })
|
||||
},
|
||||
|
||||
onSelectAll: function () {
|
||||
var regions = this.data.regions.map(function (r) {
|
||||
return Object.assign({}, r, { checked: true })
|
||||
})
|
||||
this.setData({ regions: regions })
|
||||
},
|
||||
|
||||
onSelectWavelength: function (e) {
|
||||
this.setData({ selectedWavelength: e.currentTarget.dataset.value })
|
||||
},
|
||||
|
||||
onBrightnessChange: function (e) {
|
||||
this.setData({ brightness: parseInt(e.detail.value) })
|
||||
},
|
||||
|
||||
onSelectDuration: function (e) {
|
||||
this.setData({ selectedDuration: e.currentTarget.dataset.value })
|
||||
},
|
||||
|
||||
onSelectMode: function (e) {
|
||||
this.setData({ selectedMode: e.currentTarget.dataset.value })
|
||||
},
|
||||
|
||||
onStart: function () {
|
||||
var self = this
|
||||
var mask = 0
|
||||
self.data.regions.forEach(function (r) {
|
||||
if (r.checked) mask |= r.mask
|
||||
})
|
||||
|
||||
if (mask === 0) {
|
||||
wx.showToast({ title: '请至少选择一个区域', icon: 'none' })
|
||||
return
|
||||
}
|
||||
|
||||
self.setData({ submitting: true, error: '' })
|
||||
|
||||
ble.setParams({
|
||||
region_mask: mask,
|
||||
wavelength: self.data.selectedWavelength,
|
||||
brightness: self.data.brightness,
|
||||
duration_ms: self.data.selectedDuration,
|
||||
mode: self.data.selectedMode
|
||||
}).then(function () {
|
||||
return ble.startTreatment(mask)
|
||||
}).then(function () {
|
||||
self.setData({ submitting: false })
|
||||
wx.redirectTo({
|
||||
url: '/pages/treating/treating?regions=' + mask +
|
||||
'&wavelength=' + self.data.selectedWavelength +
|
||||
'&duration=' + self.data.selectedDuration +
|
||||
'&mode=' + self.data.selectedMode
|
||||
})
|
||||
}).catch(function (err) {
|
||||
self.setData({ submitting: false, error: err.error_msg || '启动失败' })
|
||||
})
|
||||
}
|
||||
})
|
||||
@@ -0,0 +1,3 @@
|
||||
{
|
||||
"navigationBarTitleText": "护理设置"
|
||||
}
|
||||
@@ -0,0 +1,62 @@
|
||||
<view class="container">
|
||||
<view class="card">
|
||||
<view class="section-title">护理区域</view>
|
||||
<view class="region-grid">
|
||||
<view wx:for="{{regions}}" wx:key="mask" class="region-item {{item.checked ? 'region-active' : ''}}"
|
||||
bindtap="onToggleRegion" data-idx="{{index}}">
|
||||
<text>{{item.name}}</text>
|
||||
</view>
|
||||
</view>
|
||||
<view class="btn-select-all mt-10" bindtap="onSelectAll">全选</view>
|
||||
</view>
|
||||
|
||||
<view class="card">
|
||||
<view class="section-title">波长选择</view>
|
||||
<view class="wavelength-list">
|
||||
<view wx:for="{{wavelengthOptions}}" wx:key="value"
|
||||
class="wavelength-item {{selectedWavelength === item.value ? 'wavelength-active' : ''}}"
|
||||
bindtap="onSelectWavelength" data-value="{{item.value}}">
|
||||
<text class="wavelength-name">{{item.name}}</text>
|
||||
<text class="wavelength-desc">{{item.desc}}</text>
|
||||
</view>
|
||||
</view>
|
||||
</view>
|
||||
|
||||
<view class="card">
|
||||
<view class="section-title">亮度: {{brightness}}</view>
|
||||
<slider min="0" max="255" value="{{brightness}}" bindchange="onBrightnessChange"
|
||||
activeColor="#333333" block-size="20" />
|
||||
</view>
|
||||
|
||||
<view class="card">
|
||||
<view class="section-title">护理时长</view>
|
||||
<view class="duration-list">
|
||||
<view wx:for="{{durationOptions}}" wx:key="value"
|
||||
class="duration-item {{selectedDuration === item.value ? 'duration-active' : ''}}"
|
||||
bindtap="onSelectDuration" data-value="{{item.value}}">
|
||||
{{item.label}}
|
||||
</view>
|
||||
</view>
|
||||
</view>
|
||||
|
||||
<view class="card">
|
||||
<view class="section-title">护理模式</view>
|
||||
<view class="mode-list">
|
||||
<view wx:for="{{modeOptions}}" wx:key="value"
|
||||
class="mode-item {{selectedMode === item.value ? 'mode-active' : ''}}"
|
||||
bindtap="onSelectMode" data-value="{{item.value}}">
|
||||
{{item.name}}
|
||||
</view>
|
||||
</view>
|
||||
</view>
|
||||
|
||||
<view wx:if="{{error}}" class="card">
|
||||
<text class="text-error">{{error}}</text>
|
||||
</view>
|
||||
|
||||
<view class="btn-area-fixed">
|
||||
<view class="btn-primary" bindtap="onStart" disabled="{{submitting}}">
|
||||
{{submitting ? '启动中...' : '开始护理'}}
|
||||
</view>
|
||||
</view>
|
||||
</view>
|
||||
@@ -0,0 +1,98 @@
|
||||
.region-grid {
|
||||
display: flex;
|
||||
flex-wrap: wrap;
|
||||
gap: 16rpx;
|
||||
}
|
||||
|
||||
.region-item {
|
||||
padding: 12rpx 24rpx;
|
||||
border-radius: 8rpx;
|
||||
background-color: #f0f0f0;
|
||||
font-size: 26rpx;
|
||||
color: #666666;
|
||||
}
|
||||
|
||||
.region-active {
|
||||
background-color: #333333;
|
||||
color: #ffffff;
|
||||
}
|
||||
|
||||
.btn-select-all {
|
||||
font-size: 24rpx;
|
||||
color: #333333;
|
||||
text-align: right;
|
||||
}
|
||||
|
||||
.wavelength-list {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 12rpx;
|
||||
}
|
||||
|
||||
.wavelength-item {
|
||||
padding: 20rpx;
|
||||
border: 2rpx solid #eeeeee;
|
||||
border-radius: 8rpx;
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
align-items: center;
|
||||
}
|
||||
|
||||
.wavelength-active {
|
||||
border-color: #333333;
|
||||
background-color: #fafafa;
|
||||
}
|
||||
|
||||
.wavelength-name {
|
||||
font-size: 28rpx;
|
||||
font-weight: 500;
|
||||
}
|
||||
|
||||
.wavelength-desc {
|
||||
font-size: 24rpx;
|
||||
color: #999999;
|
||||
}
|
||||
|
||||
.duration-list {
|
||||
display: flex;
|
||||
gap: 16rpx;
|
||||
}
|
||||
|
||||
.duration-item {
|
||||
flex: 1;
|
||||
padding: 16rpx;
|
||||
text-align: center;
|
||||
border: 2rpx solid #eeeeee;
|
||||
border-radius: 8rpx;
|
||||
font-size: 26rpx;
|
||||
}
|
||||
|
||||
.duration-active {
|
||||
border-color: #333333;
|
||||
background-color: #333333;
|
||||
color: #ffffff;
|
||||
}
|
||||
|
||||
.mode-list {
|
||||
display: flex;
|
||||
gap: 16rpx;
|
||||
}
|
||||
|
||||
.mode-item {
|
||||
flex: 1;
|
||||
padding: 16rpx;
|
||||
text-align: center;
|
||||
border: 2rpx solid #eeeeee;
|
||||
border-radius: 8rpx;
|
||||
font-size: 26rpx;
|
||||
}
|
||||
|
||||
.mode-active {
|
||||
border-color: #333333;
|
||||
background-color: #333333;
|
||||
color: #ffffff;
|
||||
}
|
||||
|
||||
.btn-area-fixed {
|
||||
padding: 20rpx 0 40rpx;
|
||||
}
|
||||
@@ -0,0 +1,39 @@
|
||||
var ble = require('../../services/ble')
|
||||
|
||||
Page({
|
||||
data: {
|
||||
checking: false,
|
||||
result: null,
|
||||
error: ''
|
||||
},
|
||||
|
||||
onShow: function () {
|
||||
this.checkWearing()
|
||||
},
|
||||
|
||||
checkWearing: function () {
|
||||
var self = this
|
||||
self.setData({ checking: true, result: null, error: '' })
|
||||
|
||||
ble.on('status', function (status) {
|
||||
self.setData({ checking: false })
|
||||
if (status.bind_status === 1) {
|
||||
self.setData({ result: 'ok' })
|
||||
} else {
|
||||
self.setData({ result: 'fail', error: '请确认设备已正确佩戴' })
|
||||
}
|
||||
})
|
||||
|
||||
ble.queryStatus().catch(function (err) {
|
||||
self.setData({ checking: false, result: 'fail', error: err.error_msg || '查询失败' })
|
||||
})
|
||||
},
|
||||
|
||||
onNext: function () {
|
||||
wx.navigateTo({ url: '/pages/treatment-setup/treatment-setup' })
|
||||
},
|
||||
|
||||
onRetry: function () {
|
||||
this.checkWearing()
|
||||
}
|
||||
})
|
||||
@@ -0,0 +1,3 @@
|
||||
{
|
||||
"navigationBarTitleText": "确认佩戴"
|
||||
}
|
||||
@@ -0,0 +1,27 @@
|
||||
<view class="container">
|
||||
<view class="card text-center">
|
||||
<view class="section-title">确认佩戴</view>
|
||||
|
||||
<view wx:if="{{checking}}" class="text-muted mb-30">
|
||||
<view class="loading-area">
|
||||
<view class="spinner-sm"></view>
|
||||
<text>正在检测佩戴状态...</text>
|
||||
</view>
|
||||
<view class="tip-area mt-20">
|
||||
<text class="text-muted">请将设备贴合面部,确保佩戴稳固</text>
|
||||
</view>
|
||||
</view>
|
||||
|
||||
<view wx:if="{{result === 'ok'}}">
|
||||
<view class="success-mark">✓</view>
|
||||
<view class="text-success mb-30">佩戴确认成功</view>
|
||||
<view class="btn-primary" bindtap="onNext">设置护理参数</view>
|
||||
</view>
|
||||
|
||||
<view wx:if="{{result === 'fail'}}">
|
||||
<view class="fail-mark">✗</view>
|
||||
<view class="text-error mb-20">{{error}}</view>
|
||||
<view class="btn-primary" bindtap="onRetry">重新检测</view>
|
||||
</view>
|
||||
</view>
|
||||
</view>
|
||||
@@ -0,0 +1,50 @@
|
||||
.loading-area {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
align-items: center;
|
||||
padding: 40rpx 0;
|
||||
}
|
||||
|
||||
.spinner-sm {
|
||||
width: 48rpx;
|
||||
height: 48rpx;
|
||||
border: 4rpx solid #eeeeee;
|
||||
border-top-color: #333333;
|
||||
border-radius: 50%;
|
||||
animation: spin 0.8s linear infinite;
|
||||
margin-bottom: 16rpx;
|
||||
}
|
||||
|
||||
@keyframes spin {
|
||||
to { transform: rotate(360deg); }
|
||||
}
|
||||
|
||||
.success-mark {
|
||||
width: 80rpx;
|
||||
height: 80rpx;
|
||||
border-radius: 50%;
|
||||
background-color: #52c41a;
|
||||
color: #ffffff;
|
||||
font-size: 48rpx;
|
||||
line-height: 80rpx;
|
||||
text-align: center;
|
||||
margin: 20rpx auto;
|
||||
}
|
||||
|
||||
.fail-mark {
|
||||
width: 80rpx;
|
||||
height: 80rpx;
|
||||
border-radius: 50%;
|
||||
background-color: #ff4d4f;
|
||||
color: #ffffff;
|
||||
font-size: 48rpx;
|
||||
line-height: 80rpx;
|
||||
text-align: center;
|
||||
margin: 20rpx auto;
|
||||
}
|
||||
|
||||
.tip-area {
|
||||
padding: 20rpx;
|
||||
background-color: #fffbe6;
|
||||
border-radius: 8rpx;
|
||||
}
|
||||
@@ -0,0 +1,56 @@
|
||||
{
|
||||
"description": "项目配置文件",
|
||||
"packOptions": {
|
||||
"ignore": [],
|
||||
"include": []
|
||||
},
|
||||
"setting": {
|
||||
"bundle": false,
|
||||
"userConfirmedBundleSwitch": false,
|
||||
"urlCheck": true,
|
||||
"scopeDataCheck": false,
|
||||
"coverView": true,
|
||||
"es6": true,
|
||||
"postcss": true,
|
||||
"compileHotReLoad": false,
|
||||
"lazyloadPlaceholderEnable": false,
|
||||
"preloadBackgroundData": false,
|
||||
"minified": true,
|
||||
"autoAudits": false,
|
||||
"newFeature": false,
|
||||
"uglifyFileName": false,
|
||||
"uploadWithSourceMap": true,
|
||||
"useIsolateContext": true,
|
||||
"nodeModules": false,
|
||||
"enhance": true,
|
||||
"useMultiFrameRuntime": true,
|
||||
"useApiHook": true,
|
||||
"useApiHostProcess": true,
|
||||
"showShadowRootInWxmlPanel": true,
|
||||
"packNpmManually": false,
|
||||
"enableEngineNative": false,
|
||||
"packNpmRelationList": [],
|
||||
"minifyWXSS": true,
|
||||
"showES6CompileOption": false,
|
||||
"minifyWXML": true,
|
||||
"babelSetting": {
|
||||
"ignore": [],
|
||||
"disablePlugins": [],
|
||||
"outputPath": ""
|
||||
},
|
||||
"compileWorklet": false,
|
||||
"localPlugins": false,
|
||||
"disableUseStrict": false,
|
||||
"useCompilerPlugins": false,
|
||||
"condition": false,
|
||||
"swc": false,
|
||||
"disableSWC": true
|
||||
},
|
||||
"compileType": "miniprogram",
|
||||
"libVersion": "3.15.2",
|
||||
"appid": "wxc4045074ef298510",
|
||||
"projectname": "hox-beauty",
|
||||
"condition": {},
|
||||
"simulatorPluginLibVersion": {},
|
||||
"editorSetting": {}
|
||||
}
|
||||
@@ -0,0 +1,23 @@
|
||||
{
|
||||
"libVersion": "3.15.2",
|
||||
"projectname": "miniprogram",
|
||||
"condition": {},
|
||||
"setting": {
|
||||
"urlCheck": true,
|
||||
"coverView": true,
|
||||
"lazyloadPlaceholderEnable": false,
|
||||
"skylineRenderEnable": false,
|
||||
"preloadBackgroundData": false,
|
||||
"autoAudits": false,
|
||||
"useApiHook": true,
|
||||
"showShadowRootInWxmlPanel": true,
|
||||
"useStaticServer": false,
|
||||
"useLanDebug": false,
|
||||
"showES6CompileOption": false,
|
||||
"compileHotReLoad": true,
|
||||
"checkInvalidKey": true,
|
||||
"ignoreDevUnusedFiles": true,
|
||||
"bigPackageSizeSupport": false,
|
||||
"useIsolateContext": true
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,620 @@
|
||||
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 = {}
|
||||
|
||||
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(2)
|
||||
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(2, 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.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 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 writeCommand(CMD.SET_PARAMS, payload)
|
||||
}
|
||||
|
||||
function startTreatment(regionMask) {
|
||||
var mask = regionMask || REGION.FULL_FACE
|
||||
return writeCommand(CMD.START, [mask])
|
||||
}
|
||||
|
||||
function stopTreatment() {
|
||||
return writeCommand(CMD.STOP, [])
|
||||
}
|
||||
|
||||
function queryStatus() {
|
||||
return writeCommand(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 writeCommand(CMD.BIND, payload)
|
||||
}
|
||||
|
||||
function unbindDevice(userId) {
|
||||
var userBytes = hexToBytes(userId)
|
||||
var payload = [0x02].concat(userBytes)
|
||||
return writeCommand(CMD.UNBIND, payload)
|
||||
}
|
||||
|
||||
function disconnect() {
|
||||
if (_deviceId) {
|
||||
wx.closeBLEConnection({ deviceId: _deviceId })
|
||||
_deviceId = null
|
||||
}
|
||||
_connected = false
|
||||
_chars = {}
|
||||
_pendingAcks = {}
|
||||
_listeners = {}
|
||||
wx.closeBluetoothAdapter({})
|
||||
}
|
||||
|
||||
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,
|
||||
connect: connect,
|
||||
disconnect: disconnect,
|
||||
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
|
||||
}
|
||||
@@ -0,0 +1,172 @@
|
||||
var request = require('../utils/request')
|
||||
|
||||
var MQTT_BROKER = 'wxs://iotcloud.tencent.com/socket/mqtt'
|
||||
var _socketTask = null
|
||||
var _connected = false
|
||||
var _subscriptions = {}
|
||||
var _listeners = {}
|
||||
var _reconnectTimer = null
|
||||
var _heartbeatTimer = null
|
||||
var _userId = 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('mqtt emit error:', e) }
|
||||
})
|
||||
}
|
||||
|
||||
function connect(userId) {
|
||||
_userId = userId
|
||||
var clientId = 'user_' + userId
|
||||
var token = wx.getStorageSync('token')
|
||||
|
||||
_socketTask = wx.connectSocket({
|
||||
url: MQTT_BROKER,
|
||||
header: {
|
||||
'Authorization': 'Bearer ' + token
|
||||
},
|
||||
success: function () {
|
||||
console.log('mqtt connecting...')
|
||||
},
|
||||
fail: function () {
|
||||
emit('error', { msg: 'MQTT连接失败' })
|
||||
scheduleReconnect()
|
||||
}
|
||||
})
|
||||
|
||||
_socketTask.onOpen(function () {
|
||||
_connected = true
|
||||
startHeartbeat()
|
||||
subscribeUserTopics()
|
||||
emit('connected')
|
||||
})
|
||||
|
||||
_socketTask.onMessage(function (res) {
|
||||
handleMessage(res.data)
|
||||
})
|
||||
|
||||
_socketTask.onClose(function () {
|
||||
_connected = false
|
||||
stopHeartbeat()
|
||||
emit('disconnected')
|
||||
scheduleReconnect()
|
||||
})
|
||||
|
||||
_socketTask.onError(function () {
|
||||
_connected = false
|
||||
emit('error', { msg: 'MQTT连接异常' })
|
||||
})
|
||||
}
|
||||
|
||||
function subscribeUserTopics() {
|
||||
if (!_userId) return
|
||||
subscribe('users/' + _userId + '/subscription')
|
||||
subscribe('users/' + _userId + '/devices')
|
||||
subscribe('users/' + _userId + '/notification')
|
||||
}
|
||||
|
||||
function subscribe(topic) {
|
||||
_subscriptions[topic] = true
|
||||
}
|
||||
|
||||
function unsubscribe(topic) {
|
||||
delete _subscriptions[topic]
|
||||
}
|
||||
|
||||
function handleMessage(data) {
|
||||
try {
|
||||
var msg = JSON.parse(data)
|
||||
var topic = msg.topic
|
||||
|
||||
if (topic && _subscriptions[topic]) {
|
||||
if (topic.indexOf('/subscription') !== -1) {
|
||||
emit('subscription_change', msg.payload || msg)
|
||||
} else if (topic.indexOf('/devices') !== -1) {
|
||||
emit('devices_change', msg.payload || msg)
|
||||
} else if (topic.indexOf('/notification') !== -1) {
|
||||
emit('notification', msg.payload || msg)
|
||||
} else {
|
||||
emit('message', msg.payload || msg)
|
||||
}
|
||||
}
|
||||
} catch (e) {
|
||||
console.error('mqtt parse error:', e)
|
||||
}
|
||||
}
|
||||
|
||||
function publish(topic, payload) {
|
||||
if (!_connected || !_socketTask) return
|
||||
_socketTask.send({
|
||||
data: JSON.stringify({ topic: topic, payload: payload })
|
||||
})
|
||||
}
|
||||
|
||||
function startHeartbeat() {
|
||||
stopHeartbeat()
|
||||
_heartbeatTimer = setInterval(function () {
|
||||
if (_connected && _socketTask) {
|
||||
_socketTask.send({ data: JSON.stringify({ type: 'ping' }) })
|
||||
}
|
||||
}, 30000)
|
||||
}
|
||||
|
||||
function stopHeartbeat() {
|
||||
if (_heartbeatTimer) {
|
||||
clearInterval(_heartbeatTimer)
|
||||
_heartbeatTimer = null
|
||||
}
|
||||
}
|
||||
|
||||
function scheduleReconnect() {
|
||||
if (_reconnectTimer) return
|
||||
_reconnectTimer = setTimeout(function () {
|
||||
_reconnectTimer = null
|
||||
if (_userId) connect(_userId)
|
||||
}, 5000)
|
||||
}
|
||||
|
||||
function disconnect() {
|
||||
if (_reconnectTimer) {
|
||||
clearTimeout(_reconnectTimer)
|
||||
_reconnectTimer = null
|
||||
}
|
||||
stopHeartbeat()
|
||||
if (_socketTask) {
|
||||
_socketTask.close({})
|
||||
_socketTask = null
|
||||
}
|
||||
_connected = false
|
||||
_subscriptions = {}
|
||||
_listeners = {}
|
||||
_userId = null
|
||||
}
|
||||
|
||||
function isConnected() {
|
||||
return _connected
|
||||
}
|
||||
|
||||
module.exports = {
|
||||
connect: connect,
|
||||
disconnect: disconnect,
|
||||
subscribe: subscribe,
|
||||
unsubscribe: unsubscribe,
|
||||
publish: publish,
|
||||
on: on,
|
||||
off: off,
|
||||
isConnected: isConnected
|
||||
}
|
||||
@@ -0,0 +1,9 @@
|
||||
{
|
||||
"desc": "关于本文件的更多信息,请参考文档 https://developers.weixin.qq.com/miniprogram/dev/framework/sitemap.html",
|
||||
"rules": [
|
||||
{
|
||||
"action": "allow",
|
||||
"page": "*"
|
||||
}
|
||||
]
|
||||
}
|
||||
@@ -0,0 +1,145 @@
|
||||
var MOCK_TOKEN = 'mock_token_dev_' + Date.now()
|
||||
|
||||
var MOCK_USER = {
|
||||
user_id: 10001,
|
||||
open_id: 'mock_open_id_12345',
|
||||
nickname: '测试用户',
|
||||
avatar_url: '',
|
||||
phone: '',
|
||||
gender: 0,
|
||||
created_at: '2025-01-01T00:00:00.000Z'
|
||||
}
|
||||
|
||||
var MOCK_DEVICE = {
|
||||
device_id: 'DEV001',
|
||||
name: '我的光子美容仪',
|
||||
firmware_version: '1.0.0',
|
||||
battery_level: 85,
|
||||
status: 'online',
|
||||
bind_time: '2025-01-01T00:00:00.000Z'
|
||||
}
|
||||
|
||||
var MOCK_SUBSCRIPTION = {
|
||||
plan_type: 'trial',
|
||||
status: 'active',
|
||||
start_time: '2025-01-01T00:00:00.000Z',
|
||||
end_time: '2025-02-01T00:00:00.000Z',
|
||||
remaining_days: 30
|
||||
}
|
||||
|
||||
var MOCK_TREATMENTS = [
|
||||
{
|
||||
session_id: 'S20250101001',
|
||||
device_id: 'DEV001',
|
||||
regions: [1, 2],
|
||||
wavelength: 630,
|
||||
brightness: 5,
|
||||
duration: 600000,
|
||||
mode: 1,
|
||||
avg_pd: 128,
|
||||
created_at: '2025-01-15T10:30:00.000Z'
|
||||
},
|
||||
{
|
||||
session_id: 'S20250102002',
|
||||
device_id: 'DEV001',
|
||||
regions: [1],
|
||||
wavelength: 850,
|
||||
brightness: 3,
|
||||
duration: 300000,
|
||||
mode: 2,
|
||||
avg_pd: 96,
|
||||
created_at: '2025-01-14T08:00:00.000Z'
|
||||
}
|
||||
]
|
||||
|
||||
var mockHandlers = {
|
||||
'POST /api/v1/auth/login': function () {
|
||||
return {
|
||||
code: 0,
|
||||
data: {
|
||||
token: MOCK_TOKEN,
|
||||
user_id: MOCK_USER.user_id,
|
||||
user_info: MOCK_USER
|
||||
}
|
||||
}
|
||||
},
|
||||
|
||||
'POST /api/v1/auth/refresh': function () {
|
||||
return { code: 0, data: { token: MOCK_TOKEN } }
|
||||
},
|
||||
|
||||
'GET /api/v1/user/profile': function () {
|
||||
return { code: 0, data: MOCK_USER }
|
||||
},
|
||||
|
||||
'PUT /api/v1/user/profile': function (data) {
|
||||
Object.assign(MOCK_USER, data)
|
||||
return { code: 0, data: MOCK_USER }
|
||||
},
|
||||
|
||||
'POST /api/v1/device/bind': function (data) {
|
||||
var d = Object.assign({}, MOCK_DEVICE, { device_id: data.device_id || 'DEV001' })
|
||||
return {
|
||||
code: 0,
|
||||
data: {
|
||||
device: d,
|
||||
bind_token: 'mock_bind_token_' + Date.now(),
|
||||
subscription: MOCK_SUBSCRIPTION
|
||||
}
|
||||
}
|
||||
},
|
||||
|
||||
'POST /api/v1/device/unbind': function () {
|
||||
return { code: 0, data: {} }
|
||||
},
|
||||
|
||||
'GET /api/v1/device/list': function () {
|
||||
return { code: 0, data: { devices: [MOCK_DEVICE], total: 1 } }
|
||||
},
|
||||
|
||||
'GET /api/v1/device/detail': function () {
|
||||
return { code: 0, data: MOCK_DEVICE }
|
||||
},
|
||||
|
||||
'GET /api/v1/subscription/status': function () {
|
||||
return { code: 0, data: MOCK_SUBSCRIPTION }
|
||||
},
|
||||
|
||||
'POST /api/v1/subscription/purchase': function (data) {
|
||||
return {
|
||||
code: 0,
|
||||
data: {
|
||||
order_id: 'ORD' + Date.now(),
|
||||
plan_type: data.plan_type,
|
||||
status: 'paid'
|
||||
}
|
||||
}
|
||||
},
|
||||
|
||||
'POST /api/v1/subscription/verify': function () {
|
||||
return { code: 0, data: { valid: true } }
|
||||
},
|
||||
|
||||
'POST /api/v1/treatment/sync': function (data) {
|
||||
return { code: 0, data: { session_id: data.session_id || 'S' + Date.now() } }
|
||||
},
|
||||
|
||||
'GET /api/v1/treatment/history': function () {
|
||||
return { code: 0, data: { records: MOCK_TREATMENTS, total: MOCK_TREATMENTS.length } }
|
||||
}
|
||||
}
|
||||
|
||||
function handle(method, path, data) {
|
||||
var key = method + ' ' + path
|
||||
var handler = mockHandlers[key]
|
||||
if (!handler) {
|
||||
return { code: -1, message: 'mock: 未定义的接口 ' + key }
|
||||
}
|
||||
return handler(data)
|
||||
}
|
||||
|
||||
module.exports = {
|
||||
handle: handle,
|
||||
MOCK_TOKEN: MOCK_TOKEN,
|
||||
enabled: true
|
||||
}
|
||||
@@ -0,0 +1,70 @@
|
||||
var API_BASE = 'https://api.lightmask.com'
|
||||
var USE_MOCK = true
|
||||
|
||||
var mock = null
|
||||
if (USE_MOCK) {
|
||||
mock = require('./mock')
|
||||
}
|
||||
|
||||
function request(options) {
|
||||
if (USE_MOCK && mock && mock.enabled) {
|
||||
return new Promise(function (resolve) {
|
||||
setTimeout(function () {
|
||||
var result = mock.handle(options.method || 'GET', options.path, options.data)
|
||||
if (result.code === 0) {
|
||||
resolve(result.data)
|
||||
}
|
||||
}, 200)
|
||||
})
|
||||
}
|
||||
|
||||
var token = wx.getStorageSync('token')
|
||||
|
||||
return new Promise(function (resolve, reject) {
|
||||
wx.request({
|
||||
url: API_BASE + options.path,
|
||||
method: options.method || 'GET',
|
||||
data: options.data || {},
|
||||
header: Object.assign({
|
||||
'Authorization': token ? 'Bearer ' + token : '',
|
||||
'Content-Type': 'application/json',
|
||||
'X-App-Version': '1.0.0',
|
||||
'X-Platform': 'wechat'
|
||||
}, options.header || {}),
|
||||
success: function (res) {
|
||||
if (res.data && res.data.code === 0) {
|
||||
resolve(res.data.data)
|
||||
} else if (res.data && res.data.code === 1001 || res.data && res.data.code === 1002) {
|
||||
wx.removeStorageSync('token')
|
||||
wx.reLaunch({ url: '/pages/login/login' })
|
||||
reject(res.data)
|
||||
} else {
|
||||
reject(res.data || { code: -1, message: '请求失败' })
|
||||
}
|
||||
},
|
||||
fail: function (err) {
|
||||
reject({ code: 2002, message: err.errMsg || '网络异常' })
|
||||
}
|
||||
})
|
||||
})
|
||||
}
|
||||
|
||||
function get(path, data) {
|
||||
return request({ path: path, method: 'GET', data: data })
|
||||
}
|
||||
|
||||
function post(path, data) {
|
||||
return request({ path: path, method: 'POST', data: data })
|
||||
}
|
||||
|
||||
function put(path, data) {
|
||||
return request({ path: path, method: 'PUT', data: data })
|
||||
}
|
||||
|
||||
module.exports = {
|
||||
request: request,
|
||||
get: get,
|
||||
post: post,
|
||||
put: put,
|
||||
API_BASE: API_BASE
|
||||
}
|
||||
在新工单中引用
屏蔽一个用户