feat: init project with miniprogram, cloud functions and admin console

这个提交包含在:
Guoguo
2026-04-22 21:24:20 +08:00
当前提交 a84e37a6e2
修改 106 个文件,包含 5902 行新增0 行删除
+51
查看文件
@@ -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">&#10003;</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); }
}
+8
查看文件
@@ -0,0 +1,8 @@
Page({
data: {
articles: []
},
onLoad: function () {
}
})
+3
查看文件
@@ -0,0 +1,3 @@
{
"navigationBarTitleText": "发现"
}
+4
查看文件
@@ -0,0 +1,4 @@
<view class="container">
<view class="section-title">发现</view>
<view class="text-center text-muted mt-20">暂无内容</view>
</view>
+1
查看文件
@@ -0,0 +1 @@
{}
+64
查看文件
@@ -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 })
})
}
})
+3
查看文件
@@ -0,0 +1,3 @@
{
"navigationBarTitleText": "护理记录"
}
+26
查看文件
@@ -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">&#128209;</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>
+22
查看文件
@@ -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;
}
+106
查看文件
@@ -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' })
}
}
})
+3
查看文件
@@ -0,0 +1,3 @@
{
"navigationBarTitleText": "首页"
}
+52
查看文件
@@ -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">&#10095;</text>
</view>
</view>
<view class="card" wx:if="{{connected && subscription && subscription.status > 0}}">
<view class="btn-primary text-center" bindtap="onStartTreatment">开始护理</view>
</view>
</view>
+23
查看文件
@@ -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;
}
+20
查看文件
@@ -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' })
})
}
})
+4
查看文件
@@ -0,0 +1,4 @@
{
"navigationBarTitleText": "授权登录",
"navigationStyle": "custom"
}
+16
查看文件
@@ -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>
+86
查看文件
@@ -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;
}
+61
查看文件
@@ -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' })
}
}
})
}
})
+3
查看文件
@@ -0,0 +1,3 @@
{
"navigationBarTitleText": "我的"
}
+43
查看文件
@@ -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">&#10095;</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">&#10095;</text>
</view>
</view>
<view class="divider"></view>
<view class="menu-item" bindtap="onViewHistory">
<text>护理记录</text>
<text class="arrow-sm">&#10095;</text>
</view>
</view>
<view class="card">
<view class="menu-item logout" bindtap="onLogout">
<text>退出登录</text>
</view>
</view>
</view>
+60
查看文件
@@ -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;
}
+56
查看文件
@@ -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 || '绑定失败'
})
})
}
})
+3
查看文件
@@ -0,0 +1,3 @@
{
"navigationBarTitleText": "扫码绑定"
}
+29
查看文件
@@ -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>
+23
查看文件
@@ -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">&#128274;</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">&#10003;</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;
}
+96
查看文件
@@ -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 +
'&regions=' + 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()
})
}
}
})
}
})
+3
查看文件
@@ -0,0 +1,3 @@
{
"navigationBarTitleText": "护理中"
}
+33
查看文件
@@ -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>
+37
查看文件
@@ -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">&#10003;</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">&#10003;</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">&#10007;</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;
}