diff --git a/admin-console/src/config/env.js b/admin-console/src/config/env.js index db05b4b..6013e46 100644 --- a/admin-console/src/config/env.js +++ b/admin-console/src/config/env.js @@ -1,3 +1,4 @@ +// Production: change to 'prod' const ENV = 'test' const API_BASES = { diff --git a/admin-console/src/styles/common.css b/admin-console/src/styles/common.css index b846592..de6315e 100644 --- a/admin-console/src/styles/common.css +++ b/admin-console/src/styles/common.css @@ -211,3 +211,10 @@ box-sizing: border-box; resize: vertical; } + +.empty-state { + text-align: center; + padding: 40px 0; + color: #999; + font-size: 14px; +} diff --git a/admin-console/src/views/DeviceListView.vue b/admin-console/src/views/DeviceListView.vue index 4035e60..ed139f0 100644 --- a/admin-console/src/views/DeviceListView.vue +++ b/admin-console/src/views/DeviceListView.vue @@ -42,6 +42,8 @@ + 暂无数据 + + 暂无数据 + diff --git a/admin-console/src/views/RecordView.vue b/admin-console/src/views/RecordView.vue index 76a7eb0..40124fd 100644 --- a/admin-console/src/views/RecordView.vue +++ b/admin-console/src/views/RecordView.vue @@ -6,9 +6,9 @@ 用户: {{ filterUserId }} x - + ~ - + + 暂无数据 + diff --git a/admin-console/src/views/SubscriptionView.vue b/admin-console/src/views/SubscriptionView.vue index fd6b50a..467acc0 100644 --- a/admin-console/src/views/SubscriptionView.vue +++ b/admin-console/src/views/SubscriptionView.vue @@ -65,6 +65,8 @@ + 暂无数据 + 方案 - + 天数 diff --git a/admin-console/src/views/UserListView.vue b/admin-console/src/views/UserListView.vue index 9ac9a1c..83cf0c8 100644 --- a/admin-console/src/views/UserListView.vue +++ b/admin-console/src/views/UserListView.vue @@ -54,6 +54,8 @@ + 暂无数据 + diff --git a/miniprogram/config/env.js b/miniprogram/config/env.js index aee0d20..5c94745 100644 --- a/miniprogram/config/env.js +++ b/miniprogram/config/env.js @@ -1,3 +1,4 @@ +// Production: change to 'prod' to disable debug buttons and mock endpoints var ENV = 'test' var API_BASES = { diff --git a/miniprogram/pages/auto-scan/auto-scan.js b/miniprogram/pages/auto-scan/auto-scan.js index a3460ec..583f340 100644 --- a/miniprogram/pages/auto-scan/auto-scan.js +++ b/miniprogram/pages/auto-scan/auto-scan.js @@ -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' + diff --git a/miniprogram/pages/history/history.json b/miniprogram/pages/history/history.json index 733e3df..830731a 100644 --- a/miniprogram/pages/history/history.json +++ b/miniprogram/pages/history/history.json @@ -1,5 +1,6 @@ { "navigationBarTitleText": "护理记录", "navigationBarBackgroundColor": "#E6508C", - "navigationBarTextStyle": "white" + "navigationBarTextStyle": "white", + "enablePullDownRefresh": true } diff --git a/miniprogram/pages/index/index.js b/miniprogram/pages/index/index.js index 07d5009..e2c707e 100644 --- a/miniprogram/pages/index/index.js +++ b/miniprogram/pages/index/index.js @@ -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' }) diff --git a/miniprogram/pages/index/index.wxml b/miniprogram/pages/index/index.wxml index a2229f3..5925683 100644 --- a/miniprogram/pages/index/index.wxml +++ b/miniprogram/pages/index/index.wxml @@ -1,5 +1,10 @@ - + + + 加载中... + + + 📱 还没有绑定设备 @@ -11,12 +16,13 @@ - + 💆 {{deviceName || '我的光面膜'}} 已连接 未连接 + 🔋 电量 {{battery}}% diff --git a/miniprogram/pages/index/index.wxss b/miniprogram/pages/index/index.wxss index 1e8b2a9..c2b997d 100644 --- a/miniprogram/pages/index/index.wxss +++ b/miniprogram/pages/index/index.wxss @@ -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; +} diff --git a/miniprogram/pages/profile/profile.js b/miniprogram/pages/profile/profile.js index 9741085..e989c51 100644 --- a/miniprogram/pages/profile/profile.js +++ b/miniprogram/pages/profile/profile.js @@ -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: '退出登录', diff --git a/miniprogram/pages/profile/profile.wxml b/miniprogram/pages/profile/profile.wxml index 9da5de0..c8c362c 100644 --- a/miniprogram/pages/profile/profile.wxml +++ b/miniprogram/pages/profile/profile.wxml @@ -1,11 +1,18 @@ - + + + 加载中... + + + - 👤 + + 👤 + 编辑资料 @@ -55,4 +62,23 @@ 退出登录 + + + + + 编辑资料 + + + 👤 + 点击更换头像 + + + 昵称 + + + + + + + diff --git a/miniprogram/pages/profile/profile.wxss b/miniprogram/pages/profile/profile.wxss index 6f36a1b..b3f3ac5 100644 --- a/miniprogram/pages/profile/profile.wxss +++ b/miniprogram/pages/profile/profile.wxss @@ -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; diff --git a/miniprogram/pages/subscribe-plans/subscribe-plans.js b/miniprogram/pages/subscribe-plans/subscribe-plans.js index 2accec5..32097cc 100644 --- a/miniprogram/pages/subscribe-plans/subscribe-plans.js +++ b/miniprogram/pages/subscribe-plans/subscribe-plans.js @@ -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元/天' }, diff --git a/miniprogram/pages/subscribe-plans/subscribe-plans.wxml b/miniprogram/pages/subscribe-plans/subscribe-plans.wxml index eb9628d..5070aaa 100644 --- a/miniprogram/pages/subscribe-plans/subscribe-plans.wxml +++ b/miniprogram/pages/subscribe-plans/subscribe-plans.wxml @@ -20,7 +20,12 @@ 选择订阅套餐 - + + + 加载套餐中... + + + 智能模式 @@ -28,7 +33,7 @@ - + {{item.tag}} {{item.priceLabel}} @@ -37,9 +42,11 @@ - - 支付即表示同意《订阅协议》 + + + 支付即表示同意《订阅协议》 + diff --git a/miniprogram/pages/subscribe-plans/subscribe-plans.wxss b/miniprogram/pages/subscribe-plans/subscribe-plans.wxss index 8a6839b..9ea1a88 100644 --- a/miniprogram/pages/subscribe-plans/subscribe-plans.wxss +++ b/miniprogram/pages/subscribe-plans/subscribe-plans.wxss @@ -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; +} diff --git a/miniprogram/services/ble/connection.js b/miniprogram/services/ble/connection.js index ae78725..be3fa0e 100644 --- a/miniprogram/services/ble/connection.js +++ b/miniprogram/services/ble/connection.js @@ -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) diff --git a/server/.env.example b/server/.env.example index f7388ab..1359036 100644 --- a/server/.env.example +++ b/server/.env.example @@ -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 diff --git a/server/src/app.js b/server/src/app.js index 76fb93e..4c47ee0 100644 --- a/server/src/app.js +++ b/server/src/app.js @@ -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) diff --git a/server/src/lib/db.js b/server/src/lib/db.js index 2045168..044b146 100644 --- a/server/src/lib/db.js +++ b/server/src/lib/db.js @@ -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' })