feat: production readiness — feature gaps + config hardening

Miniprogram:
- Add BLE reconnect button on home page when disconnected
- Add loading states to index, profile, subscribe-plans pages
- Add profile editing (avatar + nickname) with COS upload
- Enable pull-to-refresh on history page
- Fix auto-scan self.options → self.data inconsistency
- Add console.error to silent catch blocks
- Gate 'Simple Peripheral' BLE scan behind __DEV__ flag
- Add production config comment to env.js

Admin console:
- Add empty state '暂无数据' to all 5 list views
- Replace plain text plan input with select dropdown
- Add type="date" to record date filters
- Add production config comment

Server:
- CORS origin restricted in production (env CORS_ORIGIN)
- DB pool size configurable via DB_POOL_SIZE env var
- .env.example updated with WeChat Pay + production fields
这个提交包含在:
Guoguo
2026-05-15 09:07:41 -07:00
父节点 7e036576e3
当前提交 92416261ee
修改 23 个文件,包含 421 行新增33 行删除
+1
查看文件
@@ -1,3 +1,4 @@
// Production: change to 'prod'
const ENV = 'test'
const API_BASES = {
+7
查看文件
@@ -211,3 +211,10 @@
box-sizing: border-box;
resize: vertical;
}
.empty-state {
text-align: center;
padding: 40px 0;
color: #999;
font-size: 14px;
}
@@ -42,6 +42,8 @@
</template>
</DataTable>
<view class="empty-state" v-if="records.length === 0">暂无数据</view>
<ConfirmModal
:visible="showBatchImport"
title="批量导入设备"
+2
查看文件
@@ -28,6 +28,8 @@
</view>
</view>
<view class="empty-state" v-if="logs.length === 0">暂无数据</view>
<view class="pagination">
<button class="btn-page" :disabled="page <= 1" @click="onPage(page - 1)"></button>
<button class="btn-page active">{{ page }}</button>
+4 -2
查看文件
@@ -6,9 +6,9 @@
<text class="filter-tag-text">用户: {{ filterUserId }}</text>
<text class="filter-tag-close" @click="clearUserFilter">x</text>
</view>
<input class="search-input date-input" v-model="dateFrom" placeholder="开始日期" />
<input type="date" class="search-input date-input" v-model="dateFrom" placeholder="开始日期" />
<text class="date-sep">~</text>
<input class="search-input date-input" v-model="dateTo" placeholder="结束日期" />
<input type="date" class="search-input date-input" v-model="dateTo" placeholder="结束日期" />
<input
class="search-input"
v-model="keyword"
@@ -55,6 +55,8 @@
</view>
</view>
<view class="empty-state" v-if="records.length === 0">暂无数据</view>
<view class="pagination">
<button class="btn-page" :disabled="page <= 1" @click="onPage(page - 1)"></button>
<button class="btn-page active">{{ page }}</button>
+6 -1
查看文件
@@ -65,6 +65,8 @@
</template>
</DataTable>
<view class="empty-state" v-if="records.length === 0">暂无数据</view>
<ConfirmModal
:visible="showCreate"
title="创建订阅"
@@ -79,7 +81,10 @@
</view>
<view class="form-group">
<text class="form-label">方案</text>
<input class="form-input" v-model="createForm.plan" placeholder="monthly/quarterly/yearly" />
<select v-model="createForm.plan" class="form-input">
<option value="monthly">月卡 (30)</option>
<option value="yearly">年卡 (365)</option>
</select>
</view>
<view class="form-group">
<text class="form-label">天数</text>
+2
查看文件
@@ -54,6 +54,8 @@
</view>
</view>
<view class="empty-state" v-if="users.length === 0">暂无数据</view>
<view class="pagination">
<button class="btn-page" :disabled="page <= 1" @click="onPage(page - 1)"></button>
<button class="btn-page active">{{ page }}</button>
+1
查看文件
@@ -1,3 +1,4 @@
// Production: change to 'prod' to disable debug buttons and mock endpoints
var ENV = 'test'
var API_BASES = {
+1 -1
查看文件
@@ -96,7 +96,7 @@ Page({
onMockScanDone: function () {
var self = this
if (self._progressTimer) clearInterval(self._progressTimer)
var mask = parseInt(self.options.regions) || 0x7F
var mask = parseInt(self.data.regions) || 0x7F
wx.redirectTo({
url: '/pages/treating/treating?regions=' + mask +
'&wavelength=2' +
+2 -1
查看文件
@@ -1,5 +1,6 @@
{
"navigationBarTitleText": "护理记录",
"navigationBarBackgroundColor": "#E6508C",
"navigationBarTextStyle": "white"
"navigationBarTextStyle": "white",
"enablePullDownRefresh": true
}
+18 -7
查看文件
@@ -14,7 +14,8 @@ Page({
bleState: 'disconnected',
hasDevice: false,
deviceInfo: null,
devMode: false
devMode: false,
loading: true
},
onShow: function () {
@@ -47,9 +48,9 @@ Page({
return
}
self.setData({ connected: ble.isConnected() })
self.setData({ connected: ble.isConnected(), loading: true })
api.getDevices().then(function (data) {
var p1 = api.getDevices().then(function (data) {
var devices = data.devices || []
self.setData({ hasDevice: devices.length > 0 })
if (devices.length > 0) {
@@ -59,14 +60,22 @@ Page({
deviceInfo: devices[0]
})
}
}).catch(function () {})
}).catch(function (err) {
console.error('getDevices failed', err)
})
api.getSubscription().then(function (sub) {
var p2 = api.getSubscription().then(function (sub) {
self.setData({
subscription: sub,
subRemaining: sub.remaining_days || 0
})
}).catch(function () {})
}).catch(function (err) {
console.error('getSubscription failed', err)
})
Promise.all([p1, p2]).then(function () {
self.setData({ loading: false })
})
},
onBleStatus: function (status) {
@@ -88,7 +97,9 @@ Page({
},
onConnected: function () {
self.setData({ connected: true, bleState: 'connected' })
ble.queryStatus().catch(function () {})
ble.queryStatus().catch(function (err) {
console.error('queryStatus failed', err)
})
},
onError: function (err) {
self.setData({ connected: false, bleState: 'error' })
+8 -2
查看文件
@@ -1,5 +1,10 @@
<view class="page">
<view class="page-content" wx:if="{{!hasDevice}}">
<view class="loading-container" wx:if="{{loading}}">
<view class="loading-spinner"></view>
<text class="loading-text">加载中...</text>
</view>
<view class="page-content" wx:if="{{!loading && !hasDevice}}">
<view class="empty-device">
<text class="empty-icon">📱</text>
<view class="empty-text">还没有绑定设备</view>
@@ -11,12 +16,13 @@
</view>
</view>
<view class="page-content" wx:if="{{hasDevice}}">
<view class="page-content" wx:if="{{!loading && hasDevice}}">
<view class="device-card">
<view class="device-icon">💆</view>
<view class="device-name">{{deviceName || '我的光面膜'}}</view>
<text class="status-badge status-badge-green" wx:if="{{connected}}">已连接</text>
<text class="status-badge" style="background:#ccc;" wx:else>未连接</text>
<button class="btn-secondary btn-sm" bindtap="onConnectBle" wx:if="{{hasDevice && !connected}}">重新连接</button>
<view class="device-battery" wx:if="{{connected}}">
<text>🔋</text>
<text>电量 {{battery}}%</text>
+27
查看文件
@@ -42,3 +42,30 @@
color: #999;
font-size: 32rpx;
}
.loading-container {
display: flex;
flex-direction: column;
align-items: center;
justify-content: center;
padding: 200rpx 0;
}
.loading-spinner {
width: 64rpx;
height: 64rpx;
border: 6rpx solid #f0f0f0;
border-top-color: #E6508C;
border-radius: 50%;
animation: spin 0.8s linear infinite;
}
@keyframes spin {
to { transform: rotate(360deg); }
}
.loading-text {
margin-top: 20rpx;
font-size: 28rpx;
color: #999;
}
+108 -5
查看文件
@@ -1,4 +1,5 @@
var api = require('../../utils/api')
var config = require('../../config/env')
var app = getApp()
Page({
@@ -6,7 +7,12 @@ Page({
userInfo: null,
subscription: null,
deviceCount: 0,
subRemaining: 0
subRemaining: 0,
loading: true,
editing: false,
editNickname: '',
editAvatarUrl: '',
saving: false
},
onShow: function () {
@@ -15,19 +21,29 @@ Page({
loadProfile: function () {
var self = this
api.getProfile().then(function (profile) {
self.setData({ loading: true })
var p1 = api.getProfile().then(function (profile) {
self.setData({
userInfo: profile,
deviceCount: profile.device_count || 0
})
}).catch(function () {})
}).catch(function (err) {
console.error('getProfile failed', err)
})
api.getSubscription().then(function (sub) {
var p2 = api.getSubscription().then(function (sub) {
self.setData({
subscription: sub,
subRemaining: sub.remaining_days || 0
})
}).catch(function () {})
}).catch(function (err) {
console.error('getSubscription failed', err)
})
Promise.all([p1, p2]).then(function () {
self.setData({ loading: false })
})
},
onManageDevice: function () {
@@ -75,6 +91,93 @@ Page({
wx.navigateTo({ url: '/pages/contact/contact' })
},
onEditProfile: function () {
var info = this.data.userInfo || {}
this.setData({
editing: true,
editNickname: info.nickname || '',
editAvatarUrl: info.avatar || ''
})
},
onCancelEdit: function () {
this.setData({ editing: false })
},
onEditNicknameInput: function (e) {
this.setData({ editNickname: e.detail.value || '' })
},
onPickAvatar: function () {
var self = this
wx.chooseMedia({
count: 1,
mediaType: ['image'],
sourceType: ['album', 'camera'],
success: function (res) {
if (res.tempFiles && res.tempFiles[0]) {
self.setData({ editAvatarUrl: res.tempFiles[0].tempFilePath })
}
}
})
},
uploadAvatar: function (tempPath) {
return new Promise(function (resolve, reject) {
var token = wx.getStorageSync('token')
wx.uploadFile({
url: config.API_BASE + '/api/v1/user/avatar',
filePath: tempPath,
name: 'file',
header: { Authorization: 'Bearer ' + token },
success: function (res) {
try {
var data = JSON.parse(res.data)
if (data.code === 0 && data.data && data.data.avatar) {
resolve(data.data.avatar)
} else {
reject(new Error(data.message || '上传失败'))
}
} catch (e) {
reject(new Error('上传失败'))
}
},
fail: function () { reject(new Error('上传失败')) }
})
})
},
onSaveProfile: function () {
var self = this
var nickname = (self.data.editNickname || '').trim()
if (!nickname) {
wx.showToast({ title: '昵称不能为空', icon: 'none' })
return
}
self.setData({ saving: true })
var avatarUrl = self.data.editAvatarUrl
var isLocalFile = avatarUrl && (avatarUrl.indexOf('wxfile://') === 0 || avatarUrl.indexOf('http://tmp') === 0)
var avatarPromise = isLocalFile
? self.uploadAvatar(avatarUrl)
: Promise.resolve(avatarUrl || '')
avatarPromise.then(function (permanentUrl) {
return api.updateProfile({
nickname: nickname,
avatar: permanentUrl || ''
})
}).then(function () {
self.setData({ saving: false, editing: false })
wx.showToast({ title: '保存成功', icon: 'success' })
self.loadProfile()
}).catch(function (err) {
self.setData({ saving: false })
wx.showToast({ title: err.message || '保存失败', icon: 'none' })
})
},
onLogout: function () {
wx.showModal({
title: '退出登录',
+28 -2
查看文件
@@ -1,11 +1,18 @@
<view class="page">
<view class="page-content">
<view class="loading-container" wx:if="{{loading}}">
<view class="loading-spinner"></view>
<text class="loading-text">加载中...</text>
</view>
<view class="page-content" wx:if="{{!loading}}">
<view class="user-row">
<view class="user-avatar">👤</view>
<image class="user-avatar-img" wx:if="{{userInfo.avatar}}" src="{{userInfo.avatar}}" mode="aspectFill"></image>
<view class="user-avatar" wx:else>👤</view>
<view class="user-info">
<view class="user-name">{{userInfo.nickname || '未登录'}}</view>
<view class="user-phone">{{userInfo.phone || ''}}</view>
</view>
<view class="edit-profile-btn" bindtap="onEditProfile">编辑资料</view>
</view>
<view class="sub-card" wx:if="{{subscription && subscription.status === 'active' && subRemaining > 0}}">
@@ -55,4 +62,23 @@
<view class="logout-btn" bindtap="onLogout">退出登录</view>
</view>
<!-- Edit profile modal -->
<view class="edit-mask" wx:if="{{editing}}" bindtap="onCancelEdit"></view>
<view class="edit-modal" wx:if="{{editing}}">
<view class="edit-modal-title">编辑资料</view>
<view class="edit-avatar-row" bindtap="onPickAvatar">
<image class="edit-avatar-img" wx:if="{{editAvatarUrl}}" src="{{editAvatarUrl}}" mode="aspectFill"></image>
<view class="edit-avatar-placeholder" wx:else>👤</view>
<text class="edit-avatar-hint">点击更换头像</text>
</view>
<view class="edit-field">
<text class="edit-label">昵称</text>
<input class="edit-input" value="{{editNickname}}" bindinput="onEditNicknameInput" placeholder="请输入昵称" maxlength="20" />
</view>
<view class="edit-actions">
<button class="btn-secondary edit-btn" bindtap="onCancelEdit">取消</button>
<button class="btn-primary edit-btn" bindtap="onSaveProfile" disabled="{{saving}}" loading="{{saving}}">保存</button>
</view>
</view>
</view>
+133
查看文件
@@ -107,11 +107,144 @@
color: #999;
}
.loading-container {
display: flex;
flex-direction: column;
align-items: center;
justify-content: center;
padding: 200rpx 0;
}
.loading-spinner {
width: 64rpx;
height: 64rpx;
border: 6rpx solid #f0f0f0;
border-top-color: #E6508C;
border-radius: 50%;
animation: spin 0.8s linear infinite;
}
@keyframes spin {
to { transform: rotate(360deg); }
}
.loading-text {
margin-top: 20rpx;
font-size: 28rpx;
color: #999;
}
.sub-card-inactive {
background: #f5f5f5;
border: 1px dashed #d9d9d9;
}
.user-avatar-img {
width: 88rpx;
height: 88rpx;
border-radius: 50%;
flex-shrink: 0;
}
.edit-profile-btn {
font-size: 24rpx;
color: rgba(255, 255, 255, 0.9);
border: 1rpx solid rgba(255, 255, 255, 0.6);
border-radius: 24rpx;
padding: 6rpx 20rpx;
flex-shrink: 0;
}
.edit-mask {
position: fixed;
top: 0;
left: 0;
right: 0;
bottom: 0;
background: rgba(0, 0, 0, 0.5);
z-index: 100;
}
.edit-modal {
position: fixed;
left: 10%;
right: 10%;
top: 50%;
transform: translateY(-50%);
background: #fff;
border-radius: 24rpx;
padding: 40rpx;
z-index: 101;
}
.edit-modal-title {
font-size: 32rpx;
font-weight: 600;
text-align: center;
margin-bottom: 32rpx;
color: #333;
}
.edit-avatar-row {
display: flex;
flex-direction: column;
align-items: center;
margin-bottom: 32rpx;
}
.edit-avatar-img {
width: 120rpx;
height: 120rpx;
border-radius: 50%;
}
.edit-avatar-placeholder {
width: 120rpx;
height: 120rpx;
background: #f5f5f5;
border-radius: 50%;
display: flex;
align-items: center;
justify-content: center;
font-size: 56rpx;
}
.edit-avatar-hint {
font-size: 24rpx;
color: #999;
margin-top: 12rpx;
}
.edit-field {
margin-bottom: 32rpx;
}
.edit-label {
font-size: 26rpx;
color: #666;
margin-bottom: 12rpx;
display: block;
}
.edit-input {
border: 2rpx solid #e0e0e0;
border-radius: 12rpx;
padding: 16rpx 20rpx;
font-size: 28rpx;
color: #333;
}
.edit-actions {
display: flex;
gap: 20rpx;
}
.edit-btn {
flex: 1;
margin: 0 !important;
font-size: 28rpx;
}
.logout-btn {
text-align: center;
color: #ff4d4f;
@@ -8,7 +8,8 @@ Page({
selected: 'yearly',
purchasing: false,
subscription: null,
subRemaining: 0
subRemaining: 0,
loadingPlans: true
},
onBack: function () {
@@ -31,6 +32,7 @@ Page({
loadPlans: function () {
var self = this
self.setData({ loadingPlans: true })
api.getPlans().then(function (data) {
var serverPlans = data.plans || []
var monthlyPrice = 99
@@ -54,9 +56,11 @@ Page({
}
})
if (plans.length > 0) self.setData({ plans: plans })
self.setData({ loadingPlans: false })
self.checkTrialUsed()
}).catch(function () {
self.setData({
loadingPlans: false,
plans: [
{ key: 'trial', name: '试用', price: 0, priceLabel: '免费', desc: '7天免费体验', disabled: false },
{ key: 'monthly', name: '月卡', price: 99, priceLabel: '¥99', desc: '约3.3元/天' },
@@ -20,7 +20,12 @@
<view class="page-title">选择订阅套餐</view>
<view class="service-card">
<view class="loading-plans" wx:if="{{loadingPlans}}">
<view class="loading-spinner"></view>
<text class="loading-text">加载套餐中...</text>
</view>
<view class="service-card" wx:if="{{!loadingPlans}}">
<view class="service-icon">✨</view>
<view class="service-info">
<view class="service-name">智能模式</view>
@@ -28,7 +33,7 @@
</view>
</view>
<view class="plans">
<view class="plans" wx:if="{{!loadingPlans}}">
<view class="plan {{selected === item.key ? 'selected' : ''}} {{item.disabled ? 'plan-disabled' : ''}}" wx:for="{{plans}}" wx:key="key" bindtap="{{item.disabled ? '' : 'onSelect'}}" data-plan="{{item.key}}">
<view class="plan-tag {{item.disabled ? 'plan-tag-disabled' : (selected === item.key ? 'plan-tag-active' : '')}}" wx:if="{{item.tag}}">{{item.tag}}</view>
<view class="plan-price">{{item.priceLabel}}</view>
@@ -37,9 +42,11 @@
</view>
</view>
<button class="btn-primary btn-primary-gold" bindtap="onPurchase" disabled="{{purchasing}}" loading="{{purchasing}}">
{{subscription && subscription.status === 'active' && subRemaining > 0 ? '续费' : '确认支付'}}
</button>
<view class="agreement" bindtap="onAgreement">支付即表示同意《订阅协议》</view>
<block wx:if="{{!loadingPlans}}">
<button class="btn-primary btn-primary-gold" bindtap="onPurchase" disabled="{{purchasing}}" loading="{{purchasing}}">
{{subscription && subscription.status === 'active' && subRemaining > 0 ? '续费' : '确认支付'}}
</button>
<view class="agreement" bindtap="onAgreement">支付即表示同意《订阅协议》</view>
</block>
</view>
</view>
@@ -128,3 +128,30 @@
background: #ccc;
color: #fff;
}
.loading-plans {
display: flex;
flex-direction: column;
align-items: center;
justify-content: center;
padding: 80rpx 0;
}
.loading-spinner {
width: 64rpx;
height: 64rpx;
border: 6rpx solid #f0f0f0;
border-top-color: #DCB982;
border-radius: 50%;
animation: spin 0.8s linear infinite;
}
@keyframes spin {
to { transform: rotate(360deg); }
}
.loading-text {
margin-top: 20rpx;
font-size: 28rpx;
color: #999;
}
+8 -3
查看文件
@@ -1,6 +1,7 @@
// BLE connection: scan, connect, disconnect, reconnect logic
var protocol = require('./protocol')
var devConfig = require('../../config/env')
var SERVICE = protocol.SERVICE
var CHAR = protocol.CHAR
@@ -237,9 +238,13 @@ function startScan(callbacks) {
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 ||
name.indexOf('SIMPLE PERIPHERAL') !== -1 || localName.indexOf('SIMPLE PERIPHERAL') !== -1) {
var matched = name.indexOf('HOX') !== -1 || localName.indexOf('HOX') !== -1 ||
name.indexOf('LIGHTMASK') !== -1 || localName.indexOf('LIGHTMASK') !== -1
// Dev board name - only match in dev mode
if (!matched && devConfig.__DEV__) {
matched = name.indexOf('SIMPLE PERIPHERAL') !== -1 || localName.indexOf('SIMPLE PERIPHERAL') !== -1
}
if (matched) {
wx.stopBluetoothDevicesDiscovery({})
if (callbacks.onFound) callbacks.onFound(d)
connect(d.deviceId, callbacks)
+12
查看文件
@@ -22,3 +22,15 @@ ADMIN_JWT_SECRET=replace-with-a-different-long-random-secret
ADMIN_USERNAME=admin
ADMIN_PASSWORD=admin
CORS_ORIGIN=https://admin.vsai.net.cn
WX_MCH_ID=
WX_MCH_API_V3_KEY=
WX_MCH_SERIAL_NO=
WX_MCH_PRIVATE_KEY_PATH=
WX_PAY_NOTIFY_URL=
DB_POOL_SIZE=10
COS_CDN_DOMAIN=tx.vsai.net.cn
+5 -1
查看文件
@@ -1,5 +1,6 @@
const express = require('express')
const rateLimit = require('express-rate-limit')
const config = require('./config')
const { ok, fail } = require('./lib/response')
const { authMiddleware } = require('./middleware/auth')
@@ -30,7 +31,10 @@ const uploadLimiter = rateLimit({
app.use(express.json())
app.use((req, res, next) => {
res.header('Access-Control-Allow-Origin', '*')
const origin = config.nodeEnv === 'production'
? (process.env.CORS_ORIGIN || 'https://admin.vsai.net.cn')
: '*'
res.header('Access-Control-Allow-Origin', origin)
res.header('Access-Control-Allow-Headers', 'Content-Type, Authorization, X-Device-Id, X-App-Version, X-Platform')
res.header('Access-Control-Allow-Methods', 'GET, POST, PUT, DELETE, OPTIONS')
if (req.method === 'OPTIONS') return res.sendStatus(204)
+1 -1
查看文件
@@ -12,7 +12,7 @@ function getPool() {
password: config.db.password,
database: config.db.database,
waitForConnections: true,
connectionLimit: 5,
connectionLimit: parseInt(process.env.DB_POOL_SIZE, 10) || 10,
namedPlaceholders: true,
timezone: '+08:00'
})