From a84e37a6e254f181a6be5b63e9f5eda065c1b716 Mon Sep 17 00:00:00 2001 From: Guoguo Date: Wed, 22 Apr 2026 21:24:20 +0800 Subject: [PATCH] feat: init project with miniprogram, cloud functions and admin console --- .gitignore | 6 + AGENTS.md | 24 + admin-console/index.html | 14 + admin-console/package.json | 25 + admin-console/src/App.vue | 21 + admin-console/src/main.js | 10 + admin-console/src/pages.json | 50 ++ admin-console/src/pages/dashboard/index.vue | 150 +++++ .../src/pages/device-detail/index.vue | 143 ++++ admin-console/src/pages/device/index.vue | 116 ++++ admin-console/src/pages/log/index.vue | 112 ++++ admin-console/src/pages/login/index.vue | 86 +++ admin-console/src/pages/record/index.vue | 112 ++++ admin-console/src/pages/settings/index.vue | 94 +++ .../src/pages/subscription/index.vue | 165 +++++ admin-console/src/pages/user-detail/index.vue | 152 +++++ admin-console/src/pages/user/index.vue | 102 +++ admin-console/src/store/user.js | 37 ++ admin-console/src/utils/request.js | 46 ++ admin-console/vite.config.js | 6 + cloud/common/auth.js | 62 ++ cloud/common/db.js | 45 ++ cloud/common/log.js | 27 + cloud/common/response.js | 47 ++ cloud/functions/admin/index.js | 308 +++++++++ cloud/functions/auth/index.js | 136 ++++ cloud/functions/device/index.js | 195 ++++++ cloud/functions/record/index.js | 136 ++++ cloud/functions/subscription/index.js | 138 ++++ cloud/functions/treatment/index.js | 130 ++++ cloud/functions/user/index.js | 74 +++ cloud/package.json | 14 + miniprogram/app.js | 57 ++ miniprogram/app.json | 62 ++ miniprogram/app.wxss | 144 ++++ miniprogram/pages/auto-scan/auto-scan.js | 51 ++ miniprogram/pages/auto-scan/auto-scan.json | 3 + miniprogram/pages/auto-scan/auto-scan.wxml | 19 + miniprogram/pages/auto-scan/auto-scan.wxss | 8 + .../pages/bind-success/bind-success.js | 14 + .../pages/bind-success/bind-success.json | 3 + .../pages/bind-success/bind-success.wxml | 9 + .../pages/bind-success/bind-success.wxss | 10 + miniprogram/pages/ble-connect/ble-connect.js | 61 ++ .../pages/ble-connect/ble-connect.json | 3 + .../pages/ble-connect/ble-connect.wxml | 28 + .../pages/ble-connect/ble-connect.wxss | 18 + miniprogram/pages/discover/discover.js | 8 + miniprogram/pages/discover/discover.json | 3 + miniprogram/pages/discover/discover.wxml | 4 + miniprogram/pages/discover/discover.wxss | 1 + miniprogram/pages/history/history.js | 64 ++ miniprogram/pages/history/history.json | 3 + miniprogram/pages/history/history.wxml | 26 + miniprogram/pages/history/history.wxss | 22 + miniprogram/pages/index/index.js | 106 +++ miniprogram/pages/index/index.json | 3 + miniprogram/pages/index/index.wxml | 52 ++ miniprogram/pages/index/index.wxss | 23 + miniprogram/pages/login/login.js | 20 + miniprogram/pages/login/login.json | 4 + miniprogram/pages/login/login.wxml | 16 + miniprogram/pages/login/login.wxss | 86 +++ miniprogram/pages/profile/profile.js | 61 ++ miniprogram/pages/profile/profile.json | 3 + miniprogram/pages/profile/profile.wxml | 43 ++ miniprogram/pages/profile/profile.wxss | 60 ++ miniprogram/pages/scan/scan.js | 56 ++ miniprogram/pages/scan/scan.json | 3 + miniprogram/pages/scan/scan.wxml | 29 + miniprogram/pages/scan/scan.wxss | 23 + .../pages/subscribe-plans/subscribe-plans.js | 42 ++ .../subscribe-plans/subscribe-plans.json | 3 + .../subscribe-plans/subscribe-plans.wxml | 15 + .../subscribe-plans/subscribe-plans.wxss | 27 + .../subscribe-prompt/subscribe-prompt.js | 11 + .../subscribe-prompt/subscribe-prompt.json | 3 + .../subscribe-prompt/subscribe-prompt.wxml | 9 + .../subscribe-prompt/subscribe-prompt.wxss | 4 + .../subscribe-success/subscribe-success.js | 7 + .../subscribe-success/subscribe-success.json | 3 + .../subscribe-success/subscribe-success.wxml | 8 + .../subscribe-success/subscribe-success.wxss | 11 + miniprogram/pages/treating/treating.js | 96 +++ miniprogram/pages/treating/treating.json | 3 + miniprogram/pages/treating/treating.wxml | 33 + miniprogram/pages/treating/treating.wxss | 37 ++ .../pages/treatment-done/treatment-done.js | 57 ++ .../pages/treatment-done/treatment-done.json | 3 + .../pages/treatment-done/treatment-done.wxml | 34 + .../pages/treatment-done/treatment-done.wxss | 25 + .../pages/treatment-setup/treatment-setup.js | 102 +++ .../treatment-setup/treatment-setup.json | 3 + .../treatment-setup/treatment-setup.wxml | 62 ++ .../treatment-setup/treatment-setup.wxss | 98 +++ miniprogram/pages/wear-check/wear-check.js | 39 ++ miniprogram/pages/wear-check/wear-check.json | 3 + miniprogram/pages/wear-check/wear-check.wxml | 27 + miniprogram/pages/wear-check/wear-check.wxss | 50 ++ miniprogram/project.config.json | 56 ++ miniprogram/project.private.config.json | 23 + miniprogram/services/ble.js | 620 ++++++++++++++++++ miniprogram/services/mqtt.js | 172 +++++ miniprogram/sitemap.json | 9 + miniprogram/utils/mock.js | 145 ++++ miniprogram/utils/request.js | 70 ++ 106 files changed, 5902 insertions(+) create mode 100644 .gitignore create mode 100644 AGENTS.md create mode 100644 admin-console/index.html create mode 100644 admin-console/package.json create mode 100644 admin-console/src/App.vue create mode 100644 admin-console/src/main.js create mode 100644 admin-console/src/pages.json create mode 100644 admin-console/src/pages/dashboard/index.vue create mode 100644 admin-console/src/pages/device-detail/index.vue create mode 100644 admin-console/src/pages/device/index.vue create mode 100644 admin-console/src/pages/log/index.vue create mode 100644 admin-console/src/pages/login/index.vue create mode 100644 admin-console/src/pages/record/index.vue create mode 100644 admin-console/src/pages/settings/index.vue create mode 100644 admin-console/src/pages/subscription/index.vue create mode 100644 admin-console/src/pages/user-detail/index.vue create mode 100644 admin-console/src/pages/user/index.vue create mode 100644 admin-console/src/store/user.js create mode 100644 admin-console/src/utils/request.js create mode 100644 admin-console/vite.config.js create mode 100644 cloud/common/auth.js create mode 100644 cloud/common/db.js create mode 100644 cloud/common/log.js create mode 100644 cloud/common/response.js create mode 100644 cloud/functions/admin/index.js create mode 100644 cloud/functions/auth/index.js create mode 100644 cloud/functions/device/index.js create mode 100644 cloud/functions/record/index.js create mode 100644 cloud/functions/subscription/index.js create mode 100644 cloud/functions/treatment/index.js create mode 100644 cloud/functions/user/index.js create mode 100644 cloud/package.json create mode 100644 miniprogram/app.js create mode 100644 miniprogram/app.json create mode 100644 miniprogram/app.wxss create mode 100644 miniprogram/pages/auto-scan/auto-scan.js create mode 100644 miniprogram/pages/auto-scan/auto-scan.json create mode 100644 miniprogram/pages/auto-scan/auto-scan.wxml create mode 100644 miniprogram/pages/auto-scan/auto-scan.wxss create mode 100644 miniprogram/pages/bind-success/bind-success.js create mode 100644 miniprogram/pages/bind-success/bind-success.json create mode 100644 miniprogram/pages/bind-success/bind-success.wxml create mode 100644 miniprogram/pages/bind-success/bind-success.wxss create mode 100644 miniprogram/pages/ble-connect/ble-connect.js create mode 100644 miniprogram/pages/ble-connect/ble-connect.json create mode 100644 miniprogram/pages/ble-connect/ble-connect.wxml create mode 100644 miniprogram/pages/ble-connect/ble-connect.wxss create mode 100644 miniprogram/pages/discover/discover.js create mode 100644 miniprogram/pages/discover/discover.json create mode 100644 miniprogram/pages/discover/discover.wxml create mode 100644 miniprogram/pages/discover/discover.wxss create mode 100644 miniprogram/pages/history/history.js create mode 100644 miniprogram/pages/history/history.json create mode 100644 miniprogram/pages/history/history.wxml create mode 100644 miniprogram/pages/history/history.wxss create mode 100644 miniprogram/pages/index/index.js create mode 100644 miniprogram/pages/index/index.json create mode 100644 miniprogram/pages/index/index.wxml create mode 100644 miniprogram/pages/index/index.wxss create mode 100644 miniprogram/pages/login/login.js create mode 100644 miniprogram/pages/login/login.json create mode 100644 miniprogram/pages/login/login.wxml create mode 100644 miniprogram/pages/login/login.wxss create mode 100644 miniprogram/pages/profile/profile.js create mode 100644 miniprogram/pages/profile/profile.json create mode 100644 miniprogram/pages/profile/profile.wxml create mode 100644 miniprogram/pages/profile/profile.wxss create mode 100644 miniprogram/pages/scan/scan.js create mode 100644 miniprogram/pages/scan/scan.json create mode 100644 miniprogram/pages/scan/scan.wxml create mode 100644 miniprogram/pages/scan/scan.wxss create mode 100644 miniprogram/pages/subscribe-plans/subscribe-plans.js create mode 100644 miniprogram/pages/subscribe-plans/subscribe-plans.json create mode 100644 miniprogram/pages/subscribe-plans/subscribe-plans.wxml create mode 100644 miniprogram/pages/subscribe-plans/subscribe-plans.wxss create mode 100644 miniprogram/pages/subscribe-prompt/subscribe-prompt.js create mode 100644 miniprogram/pages/subscribe-prompt/subscribe-prompt.json create mode 100644 miniprogram/pages/subscribe-prompt/subscribe-prompt.wxml create mode 100644 miniprogram/pages/subscribe-prompt/subscribe-prompt.wxss create mode 100644 miniprogram/pages/subscribe-success/subscribe-success.js create mode 100644 miniprogram/pages/subscribe-success/subscribe-success.json create mode 100644 miniprogram/pages/subscribe-success/subscribe-success.wxml create mode 100644 miniprogram/pages/subscribe-success/subscribe-success.wxss create mode 100644 miniprogram/pages/treating/treating.js create mode 100644 miniprogram/pages/treating/treating.json create mode 100644 miniprogram/pages/treating/treating.wxml create mode 100644 miniprogram/pages/treating/treating.wxss create mode 100644 miniprogram/pages/treatment-done/treatment-done.js create mode 100644 miniprogram/pages/treatment-done/treatment-done.json create mode 100644 miniprogram/pages/treatment-done/treatment-done.wxml create mode 100644 miniprogram/pages/treatment-done/treatment-done.wxss create mode 100644 miniprogram/pages/treatment-setup/treatment-setup.js create mode 100644 miniprogram/pages/treatment-setup/treatment-setup.json create mode 100644 miniprogram/pages/treatment-setup/treatment-setup.wxml create mode 100644 miniprogram/pages/treatment-setup/treatment-setup.wxss create mode 100644 miniprogram/pages/wear-check/wear-check.js create mode 100644 miniprogram/pages/wear-check/wear-check.json create mode 100644 miniprogram/pages/wear-check/wear-check.wxml create mode 100644 miniprogram/pages/wear-check/wear-check.wxss create mode 100644 miniprogram/project.config.json create mode 100644 miniprogram/project.private.config.json create mode 100644 miniprogram/services/ble.js create mode 100644 miniprogram/services/mqtt.js create mode 100644 miniprogram/sitemap.json create mode 100644 miniprogram/utils/mock.js create mode 100644 miniprogram/utils/request.js diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..76b9468 --- /dev/null +++ b/.gitignore @@ -0,0 +1,6 @@ +node_modules/ +.DS_Store +小程序及后台管理软件开发资料/ +软件系统说明.docx +*.docx +cloud/sql/ diff --git a/AGENTS.md b/AGENTS.md new file mode 100644 index 0000000..533aaad --- /dev/null +++ b/AGENTS.md @@ -0,0 +1,24 @@ +# Repository Reality + +- This repository currently contains only `软件系统说明.docx`; no source code, package manifest, lockfile, CI workflow, or existing agent instruction files were present when this file was written. +- Treat the Word document as the only verified source of truth. Do not invent build, test, lint, deploy, or startup commands until executable config is added to the repo. +- If code appears later, re-audit the repo and update this file from executable config before relying on the document alone. + +# Verified System Scope + +- Product scope from `软件系统说明.docx`: a WeChat Mini Program connects to a beauty device over BLE 5.0 GATT, cloud services run on Tencent Cloud IoT + SCF + MySQL + Redis, and the admin console is `uniapp + Vue 3 + uView Plus` deployed as H5/static hosting. +- Mini Program side is described as WeChat native (`WXML` / `WXSS` / `JS`), not `uniapp`. +- Admin console is separate from the Mini Program. Do not merge those frontend assumptions when future code is added. + +# Integration Facts Worth Preserving + +- BLE frame format in the document: header `0xAA 0x55`, then length, type, payload, XOR checksum. +- BLE services in the document: `FFE0` device info, `FFE1` data communication, `FFE2` OTA. +- MQTT topics in the document: `$iot/{product_id}/{device_name}/telemetry` and `$iot/{product_id}/{device_name}/event`. +- Cloud data model names called out in the document: `users`, `devices`, `bindings`, `subscriptions`, `sessions`, `treatment_records`, `pd_data`, `operation_logs`. + +# Working Rules For Future Sessions + +- When asked to implement code in this repo, first confirm whether source files have been added since this file was created; right now there is nothing to edit except documentation. +- If implementation details are needed beyond the document, state that they are unspecified instead of guessing architecture, package layout, or command lines. +- Prefer updating this file only after verifying new facts from checked-in config or code, not from assumptions. diff --git a/admin-console/index.html b/admin-console/index.html new file mode 100644 index 0000000..7c1cda3 --- /dev/null +++ b/admin-console/index.html @@ -0,0 +1,14 @@ + + + + + + 光子美容仪 - 管理后台 + + + + +
+ + + diff --git a/admin-console/package.json b/admin-console/package.json new file mode 100644 index 0000000..81d9b04 --- /dev/null +++ b/admin-console/package.json @@ -0,0 +1,25 @@ +{ + "name": "hox-admin-console", + "version": "1.0.0", + "private": true, + "scripts": { + "dev": "uni", + "build:h5": "uni build -p h5" + }, + "dependencies": { + "@dcloudio/uni-app": "3.0.0-4020920240930001", + "@dcloudio/uni-app-plus": "3.0.0-4020920240930001", + "@dcloudio/uni-components": "3.0.0-4020920240930001", + "@dcloudio/uni-h5": "3.0.0-4020920240930001", + "vue": "^3.4.0", + "uview-plus": "^3.2.0", + "pinia": "^2.1.0" + }, + "devDependencies": { + "@dcloudio/uni-automator": "3.0.0-4020920240930001", + "@dcloudio/uni-cli-shared": "3.0.0-4020920240930001", + "@dcloudio/vite-plugin-uni": "3.0.0-4020920240930001", + "vite": "^5.0.0", + "sass": "^1.77.0" + } +} diff --git a/admin-console/src/App.vue b/admin-console/src/App.vue new file mode 100644 index 0000000..7368caa --- /dev/null +++ b/admin-console/src/App.vue @@ -0,0 +1,21 @@ + + + diff --git a/admin-console/src/main.js b/admin-console/src/main.js new file mode 100644 index 0000000..14d3ec9 --- /dev/null +++ b/admin-console/src/main.js @@ -0,0 +1,10 @@ +import { createSSRApp } from 'vue' +import { createPinia } from 'pinia' +import App from './App.vue' + +export function createApp() { + const app = createSSRApp(App) + const pinia = createPinia() + app.use(pinia) + return { app } +} diff --git a/admin-console/src/pages.json b/admin-console/src/pages.json new file mode 100644 index 0000000..d5b06f7 --- /dev/null +++ b/admin-console/src/pages.json @@ -0,0 +1,50 @@ +{ + "pages": [ + { + "path": "pages/login/index", + "style": { "navigationBarTitleText": "管理员登录", "navigationStyle": "custom" } + }, + { + "path": "pages/dashboard/index", + "style": { "navigationBarTitleText": "仪表盘" } + }, + { + "path": "pages/device/index", + "style": { "navigationBarTitleText": "设备管理" } + }, + { + "path": "pages/device-detail/index", + "style": { "navigationBarTitleText": "设备详情" } + }, + { + "path": "pages/user/index", + "style": { "navigationBarTitleText": "用户管理" } + }, + { + "path": "pages/user-detail/index", + "style": { "navigationBarTitleText": "用户详情" } + }, + { + "path": "pages/subscription/index", + "style": { "navigationBarTitleText": "订阅管理" } + }, + { + "path": "pages/record/index", + "style": { "navigationBarTitleText": "护理记录" } + }, + { + "path": "pages/log/index", + "style": { "navigationBarTitleText": "操作日志" } + }, + { + "path": "pages/settings/index", + "style": { "navigationBarTitleText": "系统设置" } + } + ], + "globalStyle": { + "navigationBarTextStyle": "black", + "navigationBarTitleText": "光子美容仪后台", + "navigationBarBackgroundColor": "#ffffff", + "backgroundColor": "#f5f5f5" + } +} diff --git a/admin-console/src/pages/dashboard/index.vue b/admin-console/src/pages/dashboard/index.vue new file mode 100644 index 0000000..305422c --- /dev/null +++ b/admin-console/src/pages/dashboard/index.vue @@ -0,0 +1,150 @@ + + + + + diff --git a/admin-console/src/pages/device-detail/index.vue b/admin-console/src/pages/device-detail/index.vue new file mode 100644 index 0000000..03584e8 --- /dev/null +++ b/admin-console/src/pages/device-detail/index.vue @@ -0,0 +1,143 @@ + + + + + diff --git a/admin-console/src/pages/device/index.vue b/admin-console/src/pages/device/index.vue new file mode 100644 index 0000000..9588dcf --- /dev/null +++ b/admin-console/src/pages/device/index.vue @@ -0,0 +1,116 @@ + + + + + diff --git a/admin-console/src/pages/log/index.vue b/admin-console/src/pages/log/index.vue new file mode 100644 index 0000000..d9b3576 --- /dev/null +++ b/admin-console/src/pages/log/index.vue @@ -0,0 +1,112 @@ + + + + + diff --git a/admin-console/src/pages/login/index.vue b/admin-console/src/pages/login/index.vue new file mode 100644 index 0000000..60bee38 --- /dev/null +++ b/admin-console/src/pages/login/index.vue @@ -0,0 +1,86 @@ + + + + + diff --git a/admin-console/src/pages/record/index.vue b/admin-console/src/pages/record/index.vue new file mode 100644 index 0000000..7c908fe --- /dev/null +++ b/admin-console/src/pages/record/index.vue @@ -0,0 +1,112 @@ + + + + + diff --git a/admin-console/src/pages/settings/index.vue b/admin-console/src/pages/settings/index.vue new file mode 100644 index 0000000..7c50f81 --- /dev/null +++ b/admin-console/src/pages/settings/index.vue @@ -0,0 +1,94 @@ + + + + + diff --git a/admin-console/src/pages/subscription/index.vue b/admin-console/src/pages/subscription/index.vue new file mode 100644 index 0000000..f018186 --- /dev/null +++ b/admin-console/src/pages/subscription/index.vue @@ -0,0 +1,165 @@ + + + + + diff --git a/admin-console/src/pages/user-detail/index.vue b/admin-console/src/pages/user-detail/index.vue new file mode 100644 index 0000000..e4b0fe5 --- /dev/null +++ b/admin-console/src/pages/user-detail/index.vue @@ -0,0 +1,152 @@ + + + + + diff --git a/admin-console/src/pages/user/index.vue b/admin-console/src/pages/user/index.vue new file mode 100644 index 0000000..c81d4fb --- /dev/null +++ b/admin-console/src/pages/user/index.vue @@ -0,0 +1,102 @@ + + + + + diff --git a/admin-console/src/store/user.js b/admin-console/src/store/user.js new file mode 100644 index 0000000..10d356f --- /dev/null +++ b/admin-console/src/store/user.js @@ -0,0 +1,37 @@ +import { defineStore } from 'pinia' +import { ref } from 'vue' +import { post } from '../utils/request' + +export const useUserStore = defineStore('user', () => { + const token = ref(uni.getStorageSync('admin_token') || '') + const adminInfo = ref(null) + + function setToken(val) { + token.value = val + uni.setStorageSync('admin_token', val) + } + + function clearToken() { + token.value = '' + adminInfo.value = null + uni.removeStorageSync('admin_token') + } + + async function login(credentials) { + const data = await post('/api/v1/admin/login', credentials) + setToken(data.token) + adminInfo.value = { + admin_id: data.admin_id, + username: data.username, + real_name: data.real_name, + role: data.role + } + return data + } + + function isLoggedIn() { + return !!token.value + } + + return { token, adminInfo, setToken, clearToken, login, isLoggedIn } +}) diff --git a/admin-console/src/utils/request.js b/admin-console/src/utils/request.js new file mode 100644 index 0000000..79c220c --- /dev/null +++ b/admin-console/src/utils/request.js @@ -0,0 +1,46 @@ +const BASE_URL = 'https://api.lightmask.com' + +function request(options) { + const token = uni.getStorageSync('admin_token') + + return new Promise((resolve, reject) => { + uni.request({ + url: BASE_URL + options.url, + method: options.method || 'GET', + data: options.data || {}, + header: { + 'Authorization': token ? 'Bearer ' + token : '', + 'Content-Type': 'application/json', + ...options.header + }, + success(res) { + if (res.data && res.data.code === 0) { + resolve(res.data.data) + } else if (res.data && (res.data.code === 1001 || res.data.code === 1002)) { + uni.removeStorageSync('admin_token') + uni.reLaunch({ url: '/pages/login/index' }) + reject(res.data) + } else { + reject(res.data || { code: -1, message: '请求失败' }) + } + }, + fail(err) { + reject({ code: -1, message: err.errMsg || '网络异常' }) + } + }) + }) +} + +function get(url, data) { + return request({ url, method: 'GET', data }) +} + +function post(url, data) { + return request({ url, method: 'POST', data }) +} + +function put(url, data) { + return request({ url, method: 'PUT', data }) +} + +export { request, get, post, put, BASE_URL } diff --git a/admin-console/vite.config.js b/admin-console/vite.config.js new file mode 100644 index 0000000..c77da27 --- /dev/null +++ b/admin-console/vite.config.js @@ -0,0 +1,6 @@ +import { defineConfig } from 'vite' +import uni from '@dcloudio/vite-plugin-uni' + +export default defineConfig({ + plugins: [uni()] +}) diff --git a/cloud/common/auth.js b/cloud/common/auth.js new file mode 100644 index 0000000..8c1e58a --- /dev/null +++ b/cloud/common/auth.js @@ -0,0 +1,62 @@ +var jwt = require('jsonwebtoken') + +var JWT_SECRET = process.env.JWT_SECRET || 'hox_jwt_secret_change_in_production' +var JWT_EXPIRES_IN = '7d' + +function generateToken(userId, openid) { + return jwt.sign( + { user_id: userId, openid: openid }, + JWT_SECRET, + { expiresIn: JWT_EXPIRES_IN } + ) +} + +function verifyToken(token) { + try { + return jwt.verify(token, JWT_SECRET) + } catch (e) { + return null + } +} + +function generateAdminToken(adminId, username, role) { + return jwt.sign( + { admin_id: adminId, username: username, role: role, type: 'admin' }, + JWT_SECRET, + { expiresIn: JWT_EXPIRES_IN } + ) +} + +function extractToken(event) { + var header = event.headers || {} + var auth = header['Authorization'] || header['authorization'] || header['authorization'] || '' + if (auth.startsWith('Bearer ')) { + return auth.substring(7) + } + return null +} + +function extractUser(event) { + var token = extractToken(event) + if (!token) return null + var decoded = verifyToken(token) + if (!decoded || decoded.type === 'admin') return null + return decoded +} + +function extractAdmin(event) { + var token = extractToken(event) + if (!token) return null + var decoded = verifyToken(token) + if (!decoded || decoded.type !== 'admin') return null + return decoded +} + +module.exports = { + generateToken: generateToken, + verifyToken: verifyToken, + generateAdminToken: generateAdminToken, + extractToken: extractToken, + extractUser: extractUser, + extractAdmin: extractAdmin +} diff --git a/cloud/common/db.js b/cloud/common/db.js new file mode 100644 index 0000000..0a042cd --- /dev/null +++ b/cloud/common/db.js @@ -0,0 +1,45 @@ +var mysql = require('mysql2/promise') + +var pool = null + +function getPool() { + if (pool) return pool + + pool = mysql.createPool({ + host: process.env.DB_HOST || 'localhost', + port: parseInt(process.env.DB_PORT || '3306'), + user: process.env.DB_USER || 'root', + password: process.env.DB_PASSWORD || '', + database: process.env.DB_NAME || 'hox', + waitForConnections: true, + connectionLimit: 10, + charset: 'utf8mb4' + }) + + return pool +} + +async function query(sql, params) { + var p = getPool() + var [rows] = await p.execute(sql, params) + return rows +} + +async function insert(sql, params) { + var p = getPool() + var [result] = await p.execute(sql, params) + return result +} + +async function update(sql, params) { + var p = getPool() + var [result] = await p.execute(sql, params) + return result +} + +module.exports = { + query: query, + insert: insert, + update: update, + getPool: getPool +} diff --git a/cloud/common/log.js b/cloud/common/log.js new file mode 100644 index 0000000..f0c1188 --- /dev/null +++ b/cloud/common/log.js @@ -0,0 +1,27 @@ +var db = require('./db') + +async function writeLog(options) { + try { + await db.insert( + 'INSERT INTO operation_logs (operator_type, operator_id, target_type, target_id, action, before_snapshot, after_snapshot, ip_address, user_agent, remark) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?)', + [ + options.operator_type || 2, + options.operator_id || null, + options.target_type || '', + options.target_id || null, + options.action || '', + options.before_snapshot ? JSON.stringify(options.before_snapshot) : null, + options.after_snapshot ? JSON.stringify(options.after_snapshot) : null, + options.ip_address || null, + options.user_agent || null, + options.remark || null + ] + ) + } catch (e) { + console.error('writeLog failed:', e.message) + } +} + +module.exports = { + writeLog: writeLog +} diff --git a/cloud/common/response.js b/cloud/common/response.js new file mode 100644 index 0000000..63279da --- /dev/null +++ b/cloud/common/response.js @@ -0,0 +1,47 @@ +var RESPONSE = { + success: function (data, message) { + return { + code: 0, + message: message || 'success', + data: data || null + } + }, + error: function (code, message) { + return { + code: code || -1, + message: message || 'error', + data: null + } + }, + paginate: function (list, total, page, pageSize) { + return { + code: 0, + message: 'success', + data: { + total: total, + page: page, + page_size: pageSize, + records: list + } + } + } +} + +var ERROR_CODES = { + INVALID_TOKEN: { code: 1001, message: 'invalid_token' }, + TOKEN_EXPIRED: { code: 1002, message: 'token_expired' }, + PERMISSION_DENIED: { code: 1003, message: 'permission_denied' }, + USER_NOT_FOUND: { code: 1004, message: 'user_not_found' }, + DEVICE_NOT_FOUND: { code: 1005, message: 'device_not_found' }, + DEVICE_NOT_BOUND: { code: 1006, message: 'device_not_bound' }, + SUBSCRIPTION_REQUIRED: { code: 1007, message: 'subscription_required' }, + SUBSCRIPTION_EXPIRED: { code: 1008, message: 'subscription_expired' }, + PARAM_ERROR: { code: 2001, message: 'param_error' }, + NETWORK_ERROR: { code: 2002, message: 'network_error' }, + SERVER_ERROR: { code: 3001, message: 'server_error' } +} + +module.exports = { + RESPONSE: RESPONSE, + ERROR_CODES: ERROR_CODES +} diff --git a/cloud/functions/admin/index.js b/cloud/functions/admin/index.js new file mode 100644 index 0000000..c72385b --- /dev/null +++ b/cloud/functions/admin/index.js @@ -0,0 +1,308 @@ +var db = require('../../common/db') +var auth = require('../../common/auth') +var log = require('../../common/log') +var RESPONSE = require('../../common/response').RESPONSE +var ERROR_CODES = require('../../common/response').ERROR_CODES +var bcrypt = require('bcryptjs') + +exports.main_handler = async function (event) { + try { + var method = event.httpMethod || 'GET' + var path = event.path || '' + var pathParams = event.pathParameters || {} + var body = parseBody(event) + + if (method === 'POST' && path === '/api/v1/admin/login') { + return await adminLogin(body, event) + } + + var admin = auth.extractAdmin(event) + if (!admin) return RESPONSE.error(ERROR_CODES.INVALID_TOKEN.code, ERROR_CODES.INVALID_TOKEN.message) + + if (method === 'GET' && path === '/api/v1/admin/dashboard') { + return await getDashboard() + } + if (method === 'GET' && path === '/api/v1/admin/devices' && !pathParams.device_id) { + return await listDevices(body) + } + if (method === 'GET' && pathParams.device_id && !path.match(/\/command$/)) { + return await getDeviceDetail(pathParams.device_id) + } + if (method === 'POST' && path.match(/\/command$/)) { + return await sendDeviceCommand(pathParams.device_id, body) + } + if (method === 'GET' && path === '/api/v1/admin/users' && !pathParams.user_id) { + return await listUsers(body) + } + if (method === 'GET' && pathParams.user_id && path.indexOf('/admin/users/') !== -1) { + return await getUserDetail(pathParams.user_id) + } + if (method === 'GET' && path === '/api/v1/admin/subscriptions') { + return await listSubscriptions(body) + } + if (method === 'POST' && path === '/api/v1/admin/subscriptions') { + return await createSubscription(event, body) + } + if (method === 'GET' && path === '/api/v1/admin/logs') { + return await getLogs(body) + } + + return RESPONSE.error(ERROR_CODES.PARAM_ERROR.code, 'unknown route') + } catch (err) { + console.error('admin error:', err) + return RESPONSE.error(ERROR_CODES.SERVER_ERROR.code, err.message) + } +} + +async function adminLogin(body, event) { + if (!body.username || !body.password) { + return RESPONSE.error(ERROR_CODES.PARAM_ERROR.code, 'missing username or password') + } + + var admins = await db.query( + 'SELECT id, username, password_hash, role, real_name FROM admins WHERE username = ? AND status = 1 AND deleted_at IS NULL LIMIT 1', + [body.username] + ) + if (admins.length === 0) { + return RESPONSE.error(ERROR_CODES.PERMISSION_DENIED.code, 'invalid credentials') + } + + var admin = admins[0] + var valid = bcrypt.compareSync(body.password, admin.password_hash) + if (!valid) { + return RESPONSE.error(ERROR_CODES.PERMISSION_DENIED.code, 'invalid credentials') + } + + await db.update( + 'UPDATE admins SET last_login_at = NOW() WHERE id = ?', + [admin.id] + ) + + var token = auth.generateAdminToken(admin.id, admin.username, admin.role) + + await log.writeLog({ + operator_type: 1, + operator_id: admin.id, + target_type: 'admin', + target_id: String(admin.id), + action: 'login', + ip_address: getClientIp(event) + }) + + return RESPONSE.success({ + token: token, + admin_id: String(admin.id), + username: admin.username, + real_name: admin.real_name, + role: admin.role + }) +} + +async function getDashboard() { + var userCount = await db.query('SELECT COUNT(*) AS cnt FROM users WHERE deleted_at IS NULL') + var deviceCount = await db.query('SELECT COUNT(*) AS cnt FROM devices WHERE deleted_at IS NULL') + var activeDevices = await db.query('SELECT COUNT(*) AS cnt FROM devices WHERE status = 2 AND deleted_at IS NULL') + var subCount = await db.query('SELECT COUNT(*) AS cnt FROM subscriptions WHERE status = 1 AND expired_at > NOW()') + var trialCount = await db.query('SELECT COUNT(*) AS cnt FROM subscriptions WHERE type = 1') + var treatmentCount = await db.query('SELECT COUNT(*) AS cnt FROM treatment_records') + var todayTreatments = await db.query( + "SELECT COUNT(*) AS cnt FROM treatment_records WHERE DATE(created_at) = CURDATE()" + ) + + return RESPONSE.success({ + user_count: userCount[0].cnt, + device_count: deviceCount[0].cnt, + active_device_count: activeDevices[0].cnt, + subscription_count: subCount[0].cnt, + trial_count: trialCount[0].cnt, + treatment_count: treatmentCount[0].cnt, + today_treatment_count: todayTreatments[0].cnt + }) +} + +async function listDevices(body) { + var page = parseInt(body.page) || 1 + var pageSize = parseInt(body.page_size) || 20 + var offset = (page - 1) * pageSize + + var total = await db.query('SELECT COUNT(*) AS cnt FROM devices WHERE deleted_at IS NULL') + var devices = await db.query( + 'SELECT d.id, d.device_id, d.device_name, d.device_type, d.hw_version, d.fw_version, d.status, d.activated_at, d.created_at, ' + + 'u.nickname AS bound_user, b.bound_at ' + + 'FROM devices d ' + + 'LEFT JOIN bindings b ON d.id = b.device_id AND b.status = 1 ' + + 'LEFT JOIN users u ON b.user_id = u.id AND u.deleted_at IS NULL ' + + 'WHERE d.deleted_at IS NULL ' + + 'ORDER BY d.created_at DESC LIMIT ? OFFSET ?', + [pageSize, offset] + ) + + return RESPONSE.paginate(devices, total[0].cnt, page, pageSize) +} + +async function getDeviceDetail(deviceId) { + var devices = await db.query( + 'SELECT * FROM devices WHERE device_id = ? AND deleted_at IS NULL LIMIT 1', + [deviceId] + ) + if (devices.length === 0) { + return RESPONSE.error(ERROR_CODES.DEVICE_NOT_FOUND.code, ERROR_CODES.DEVICE_NOT_FOUND.message) + } + + var bindings = await db.query( + 'SELECT b.*, u.nickname, u.avatar_url FROM bindings b JOIN users u ON b.user_id = u.id WHERE b.device_id = ? ORDER BY b.bound_at DESC', + [devices[0].id] + ) + + return RESPONSE.success(Object.assign({}, devices[0], { binding_history: bindings })) +} + +async function sendDeviceCommand(deviceId, body) { + var devices = await db.query( + 'SELECT id, device_id, product_id, device_name FROM devices WHERE device_id = ? AND deleted_at IS NULL LIMIT 1', + [deviceId] + ) + if (devices.length === 0) { + return RESPONSE.error(ERROR_CODES.DEVICE_NOT_FOUND.code, ERROR_CODES.DEVICE_NOT_FOUND.message) + } + + return RESPONSE.success({ message: 'command queued', device_id: deviceId, command: body }) +} + +async function listUsers(body) { + var page = parseInt(body.page) || 1 + var pageSize = parseInt(body.page_size) || 20 + var offset = (page - 1) * pageSize + + var total = await db.query('SELECT COUNT(*) AS cnt FROM users WHERE deleted_at IS NULL') + var users = await db.query( + 'SELECT u.id AS user_id, u.nickname, u.avatar_url, u.phone, u.status, u.created_at, ' + + 'COUNT(DISTINCT b.id) AS device_count, ' + + 'MAX(s.expired_at) AS subscription_expire ' + + 'FROM users u ' + + 'LEFT JOIN bindings b ON u.id = b.user_id AND b.status = 1 ' + + 'LEFT JOIN subscriptions s ON u.id = s.user_id AND s.status = 1 AND s.expired_at > NOW() ' + + 'WHERE u.deleted_at IS NULL ' + + 'GROUP BY u.id ' + + 'ORDER BY u.created_at DESC LIMIT ? OFFSET ?', + [pageSize, offset] + ) + + return RESPONSE.paginate(users, total[0].cnt, page, pageSize) +} + +async function getUserDetail(userId) { + var users = await db.query( + 'SELECT id AS user_id, openid, nickname, avatar_url, phone, status, created_at FROM users WHERE id = ? AND deleted_at IS NULL LIMIT 1', + [userId] + ) + if (users.length === 0) { + return RESPONSE.error(ERROR_CODES.USER_NOT_FOUND.code, ERROR_CODES.USER_NOT_FOUND.message) + } + + var bindings = await db.query( + 'SELECT b.bound_at, d.device_id, d.device_name, d.fw_version FROM bindings b JOIN devices d ON b.device_id = d.id WHERE b.user_id = ? AND b.status = 1', + [userId] + ) + + var subs = await db.query( + 'SELECT type, plan, status, started_at, expired_at, source FROM subscriptions WHERE user_id = ? ORDER BY created_at DESC LIMIT 5', + [userId] + ) + + var treatments = await db.query( + 'SELECT session_id, regions, total_duration_ms, mode, started_at, ended_at FROM treatment_records WHERE user_id = ? ORDER BY started_at DESC LIMIT 5', + [userId] + ) + + return RESPONSE.success(Object.assign({}, users[0], { + devices: bindings, + subscriptions: subs, + recent_treatments: treatments + })) +} + +async function listSubscriptions(body) { + var page = parseInt(body.page) || 1 + var pageSize = parseInt(body.page_size) || 20 + var offset = (page - 1) * pageSize + + var total = await db.query('SELECT COUNT(*) AS cnt FROM subscriptions') + var subs = await db.query( + 'SELECT s.id, s.user_id, u.nickname, s.type, s.plan, s.status, s.trial_used, s.started_at, s.expired_at, s.source ' + + 'FROM subscriptions s ' + + 'JOIN users u ON s.user_id = u.id ' + + 'ORDER BY s.created_at DESC LIMIT ? OFFSET ?', + [pageSize, offset] + ) + + return RESPONSE.paginate(subs, total[0].cnt, page, pageSize) +} + +async function createSubscription(event, body) { + if (!body.user_id || !body.plan || !body.days) { + return RESPONSE.error(ERROR_CODES.PARAM_ERROR.code, 'missing user_id, plan or days') + } + + await db.insert( + 'INSERT INTO subscriptions (user_id, type, plan, status, started_at, expired_at, source) VALUES (?, 2, ?, 1, NOW(), DATE_ADD(NOW(), INTERVAL ? DAY), ?)', + [body.user_id, body.plan, body.days, body.source || 'manual_create'] + ) + + await log.writeLog({ + operator_type: 1, + operator_id: event.admin_id, + target_type: 'subscription', + target_id: String(body.user_id), + action: 'create', + remark: 'plan=' + body.plan + ',days=' + body.days + }) + + return RESPONSE.success({ message: 'success' }) +} + +async function getLogs(body) { + var page = parseInt(body.page) || 1 + var pageSize = parseInt(body.page_size) || 20 + var offset = (page - 1) * pageSize + + var where = '1=1' + var params = [] + + if (body.type) { + where += ' AND target_type = ?' + params.push(body.type) + } + if (body.device_id) { + where += ' AND target_id = ?' + params.push(body.device_id) + } + if (body.start_time) { + where += ' AND created_at >= ?' + params.push(body.start_time) + } + if (body.end_time) { + where += ' AND created_at <= ?' + params.push(body.end_time) + } + + var total = await db.query('SELECT COUNT(*) AS cnt FROM operation_logs WHERE ' + where, params) + var logs = await db.query( + 'SELECT * FROM operation_logs WHERE ' + where + ' ORDER BY created_at DESC LIMIT ? OFFSET ?', + params.concat([pageSize, offset]) + ) + + return RESPONSE.paginate(logs, total[0].cnt, page, pageSize) +} + +function parseBody(event) { + if (event.body) { + try { return JSON.parse(event.body) } catch (e) { return {} } + } + return event.queryStringParameters || {} +} + +function getClientIp(event) { + var headers = event.headers || {} + return headers['x-forwarded-for'] || headers['X-Forwarded-For'] || '' +} diff --git a/cloud/functions/auth/index.js b/cloud/functions/auth/index.js new file mode 100644 index 0000000..69d042e --- /dev/null +++ b/cloud/functions/auth/index.js @@ -0,0 +1,136 @@ +var db = require('../../common/db') +var auth = require('../../common/auth') +var log = require('../../common/log') +var RESPONSE = require('../../common/response').RESPONSE +var ERROR_CODES = require('../../common/response').ERROR_CODES + +exports.main_handler = async function (event) { + try { + var method = event.httpMethod || 'GET' + var path = event.path || '' + var body = parseBody(event) + + if (method === 'POST' && path === '/api/v1/auth/login') { + return await wxLogin(body, event) + } + + if (method === 'POST' && path === '/api/v1/auth/refresh') { + return await refreshToken(body) + } + + return RESPONSE.error(ERROR_CODES.PARAM_ERROR.code, 'unknown route') + } catch (err) { + console.error('auth error:', err) + return RESPONSE.error(ERROR_CODES.SERVER_ERROR.code, err.message) + } +} + +async function wxLogin(body, event) { + if (!body.code) { + return RESPONSE.error(ERROR_CODES.PARAM_ERROR.code, 'missing code') + } + + var wxResult = await requestWxSession(body.code) + if (!wxResult || !wxResult.openid) { + return RESPONSE.error(ERROR_CODES.INVALID_TOKEN.code, 'wechat login failed') + } + + var users = await db.query( + 'SELECT id, openid, nickname, avatar_url, phone, status FROM users WHERE openid = ? AND deleted_at IS NULL LIMIT 1', + [wxResult.openid] + ) + + var userId + var isNew = false + if (users.length === 0) { + var result = await db.insert( + 'INSERT INTO users (openid, nickname, avatar_url, status) VALUES (?, ?, ?, 1)', + [wxResult.openid, body.nickname || null, body.avatar_url || null] + ) + userId = result.insertId + isNew = true + } else { + userId = users[0].id + if (body.nickname || body.avatar_url) { + await db.update( + 'UPDATE users SET nickname = COALESCE(?, nickname), avatar_url = COALESCE(?, avatar_url) WHERE id = ?', + [body.nickname || null, body.avatar_url || null, userId] + ) + } + } + + var token = auth.generateToken(userId, wxResult.openid) + + await log.writeLog({ + operator_type: 2, + operator_id: userId, + target_type: 'user', + target_id: String(userId), + action: isNew ? 'create' : 'login', + ip_address: getClientIp(event) + }) + + return RESPONSE.success({ + token: token, + user_id: String(userId), + user_info: { + id: userId, + nickname: body.nickname || null, + avatar_url: body.avatar_url || null + }, + expires_in: 604800 + }) +} + +async function refreshToken(body) { + if (!body.refresh_token) { + return RESPONSE.error(ERROR_CODES.PARAM_ERROR.code, 'missing refresh_token') + } + + var decoded = auth.verifyToken(body.refresh_token) + if (!decoded || decoded.type === 'admin') { + return RESPONSE.error(ERROR_CODES.TOKEN_EXPIRED.code, 'invalid refresh_token') + } + + var token = auth.generateToken(decoded.user_id, decoded.openid) + return RESPONSE.success({ + token: token, + expires_in: 604800 + }) +} + +async function requestWxSession(code) { + var appId = process.env.WX_APPID || '' + var appSecret = process.env.WX_APP_SECRET || '' + var url = 'https://api.weixin.qq.com/sns/jscode2session?appid=' + appId + + '&secret=' + appSecret + + '&js_code=' + code + + '&grant_type=authorization_code' + + var https = require('https') + return new Promise(function (resolve) { + https.get(url, function (res) { + var data = '' + res.on('data', function (chunk) { data += chunk }) + res.on('end', function () { + try { + resolve(JSON.parse(data)) + } catch (e) { + resolve(null) + } + }) + }).on('error', function () { resolve(null) }) + }) +} + +function parseBody(event) { + if (event.body) { + try { return JSON.parse(event.body) } catch (e) { return {} } + } + return event.queryStringParameters || {} +} + +function getClientIp(event) { + var headers = event.headers || {} + return headers['x-forwarded-for'] || headers['X-Forwarded-For'] || headers['x-real-ip'] || '' +} diff --git a/cloud/functions/device/index.js b/cloud/functions/device/index.js new file mode 100644 index 0000000..7a24865 --- /dev/null +++ b/cloud/functions/device/index.js @@ -0,0 +1,195 @@ +var db = require('../../common/db') +var auth = require('../../common/auth') +var log = require('../../common/log') +var RESPONSE = require('../../common/response').RESPONSE +var ERROR_CODES = require('../../common/response').ERROR_CODES +var uuid = require('uuid') + +exports.main_handler = async function (event) { + try { + var method = event.httpMethod || 'GET' + var path = event.path || '' + var pathParams = event.pathParameters || {} + var body = parseBody(event) + + if (method === 'POST' && path === '/api/v1/device/bind') { + return await bindDevice(event, body) + } + if (method === 'POST' && path === '/api/v1/device/unbind') { + return await unbindDevice(event, body) + } + if (method === 'GET' && path === '/api/v1/device/list') { + return await listDevices(event) + } + if (method === 'GET' && pathParams.device_id) { + return await getDeviceDetail(event, pathParams.device_id) + } + + return RESPONSE.error(ERROR_CODES.PARAM_ERROR.code, 'unknown route') + } catch (err) { + console.error('device error:', err) + return RESPONSE.error(ERROR_CODES.SERVER_ERROR.code, err.message) + } +} + +async function bindDevice(event, body) { + var user = auth.extractUser(event) + if (!user) return RESPONSE.error(ERROR_CODES.INVALID_TOKEN.code, ERROR_CODES.INVALID_TOKEN.message) + + if (!body.device_id) { + return RESPONSE.error(ERROR_CODES.PARAM_ERROR.code, 'missing device_id') + } + + var devices = await db.query( + 'SELECT id, device_id, device_name, product_id, status FROM devices WHERE device_id = ? AND deleted_at IS NULL LIMIT 1', + [body.device_id] + ) + if (devices.length === 0) { + return RESPONSE.error(ERROR_CODES.DEVICE_NOT_FOUND.code, ERROR_CODES.DEVICE_NOT_FOUND.message) + } + var device = devices[0] + + var existing = await db.query( + 'SELECT id FROM bindings WHERE device_id = ? AND status = 1', + [device.id] + ) + if (existing.length > 0) { + var existingUser = await db.query('SELECT id FROM users WHERE id = ? AND deleted_at IS NULL', [existing[0].user_id]) + if (existingUser.length > 0 && String(existing[0].user_id) !== String(user.user_id)) { + return RESPONSE.error(ERROR_CODES.PERMISSION_DENIED.code, 'device already bound') + } + if (existingUser.length > 0 && String(existing[0].user_id) === String(user.user_id)) { + var bindToken = uuid.v4().replace(/-/g, '').substring(0, 16) + await db.update( + 'UPDATE bindings SET bind_token = ?, updated_at = NOW() WHERE id = ?', + [bindToken, existing[0].id] + ) + return RESPONSE.success({ bind_token: bindToken, bind_expires: 300 }) + } + } + + var bindToken = uuid.v4().replace(/-/g, '').substring(0, 16) + + await db.insert( + 'INSERT INTO bindings (user_id, device_id, bind_token, status, bound_at) VALUES (?, ?, ?, 1, NOW())', + [user.user_id, device.id, bindToken] + ) + + await db.update( + 'UPDATE devices SET status = 2, activated_at = COALESCE(activated_at, NOW()) WHERE id = ?', + [device.id] + ) + + await grantTrialIfNeeded(user.user_id) + + await log.writeLog({ + operator_type: 2, + operator_id: user.user_id, + target_type: 'device', + target_id: device.device_id, + action: 'bind', + ip_address: getClientIp(event) + }) + + return RESPONSE.success({ bind_token: bindToken, bind_expires: 300 }) +} + +async function unbindDevice(event, body) { + var user = auth.extractUser(event) + if (!user) return RESPONSE.error(ERROR_CODES.INVALID_TOKEN.code, ERROR_CODES.INVALID_TOKEN.message) + + if (!body.device_id) { + return RESPONSE.error(ERROR_CODES.PARAM_ERROR.code, 'missing device_id') + } + + var devices = await db.query( + 'SELECT id, device_id FROM devices WHERE device_id = ? AND deleted_at IS NULL LIMIT 1', + [body.device_id] + ) + if (devices.length === 0) { + return RESPONSE.error(ERROR_CODES.DEVICE_NOT_FOUND.code, ERROR_CODES.DEVICE_NOT_FOUND.message) + } + + var result = await db.update( + 'UPDATE bindings SET status = 2, unbound_at = NOW(), updated_at = NOW() WHERE user_id = ? AND device_id = ? AND status = 1', + [user.user_id, devices[0].id] + ) + if (result.affectedRows === 0) { + return RESPONSE.error(ERROR_CODES.DEVICE_NOT_BOUND.code, ERROR_CODES.DEVICE_NOT_BOUND.message) + } + + await log.writeLog({ + operator_type: 2, + operator_id: user.user_id, + target_type: 'device', + target_id: body.device_id, + action: 'unbind', + ip_address: getClientIp(event) + }) + + return RESPONSE.success({ message: 'success' }) +} + +async function listDevices(event) { + var user = auth.extractUser(event) + if (!user) return RESPONSE.error(ERROR_CODES.INVALID_TOKEN.code, ERROR_CODES.INVALID_TOKEN.message) + + var bindings = await db.query( + 'SELECT b.bound_at AS bind_time, b.bind_token, d.device_id, d.device_name, d.fw_version AS firmware_version, d.hw_version, d.status ' + + 'FROM bindings b ' + + 'JOIN devices d ON b.device_id = d.id ' + + 'WHERE b.user_id = ? AND b.status = 1 AND d.deleted_at IS NULL ' + + 'ORDER BY b.bound_at DESC', + [user.user_id] + ) + + return RESPONSE.success({ devices: bindings }) +} + +async function getDeviceDetail(event, deviceId) { + var user = auth.extractUser(event) + if (!user) return RESPONSE.error(ERROR_CODES.INVALID_TOKEN.code, ERROR_CODES.INVALID_TOKEN.message) + + var bindings = await db.query( + 'SELECT b.id, b.bound_at AS bind_time FROM bindings b ' + + 'JOIN devices d ON b.device_id = d.id ' + + 'WHERE b.user_id = ? AND b.status = 1 AND d.device_id = ? AND d.deleted_at IS NULL LIMIT 1', + [user.user_id, deviceId] + ) + if (bindings.length === 0) { + return RESPONSE.error(ERROR_CODES.DEVICE_NOT_FOUND.code, ERROR_CODES.DEVICE_NOT_FOUND.message) + } + + var devices = await db.query( + 'SELECT device_id, device_name, device_type, hw_version, fw_version AS firmware_version, status, activated_at FROM devices WHERE device_id = ? LIMIT 1', + [deviceId] + ) + + return RESPONSE.success(Object.assign({}, devices[0], { bind_time: bindings[0].bind_time })) +} + +async function grantTrialIfNeeded(userId) { + var existing = await db.query( + 'SELECT id FROM subscriptions WHERE user_id = ? AND type = 1 LIMIT 1', + [userId] + ) + if (existing.length > 0) return + + await db.insert( + 'INSERT INTO subscriptions (user_id, type, plan, status, trial_used, started_at, expired_at, source) ' + + 'VALUES (?, 1, NULL, 1, 1, NOW(), DATE_ADD(NOW(), INTERVAL 7 DAY), ?)', + [userId, 'trial_grant'] + ) +} + +function parseBody(event) { + if (event.body) { + try { return JSON.parse(event.body) } catch (e) { return {} } + } + return event.queryStringParameters || {} +} + +function getClientIp(event) { + var headers = event.headers || {} + return headers['x-forwarded-for'] || headers['X-Forwarded-For'] || '' +} diff --git a/cloud/functions/record/index.js b/cloud/functions/record/index.js new file mode 100644 index 0000000..c92ae63 --- /dev/null +++ b/cloud/functions/record/index.js @@ -0,0 +1,136 @@ +var db = require('../common/db') +var RESPONSE = require('../common/response').RESPONSE +var ERROR_CODES = require('../common/response').ERROR_CODES + +exports.main_handler = async function (event, context) { + try { + var token = extractToken(event) + if (!token) { + return RESPONSE.error(ERROR_CODES.UNAUTHORIZED.code, ERROR_CODES.UNAUTHORIZED.message) + } + + var user = await verifyToken(token) + if (!user) { + return RESPONSE.error(ERROR_CODES.TOKEN_EXPIRED.code, ERROR_CODES.TOKEN_EXPIRED.message) + } + + var body = parseBody(event) + + switch (event.path || event.action) { + case '/record/sync': + return await syncRecord(user, body) + case '/record/list': + return await listRecords(user, body) + case '/record/detail': + return await getRecordDetail(user, body) + default: + return RESPONSE.error(ERROR_CODES.PARAM_ERROR.code, '未知操作') + } + } catch (err) { + return RESPONSE.error(ERROR_CODES.INTERNAL_ERROR.code, err.message) + } +} + +async function syncRecord(user, body) { + if (!body.device_id || !body.started_at || !body.ended_at) { + return RESPONSE.error(ERROR_CODES.PARAM_ERROR.code, '参数不完整') + } + + var dbConfig = getDbConfig() + + var sessionResult = await db.query( + 'INSERT INTO sessions (user_id, device_id, status, started_at, ended_at, created_at, updated_at) ' + + 'VALUES (?, ?, 2, ?, ?, NOW(), NOW())', + [user.id, body.device_id, body.started_at, body.ended_at], + dbConfig + ) + + var sessionId = sessionResult.insertId + + await db.query( + 'INSERT INTO treatment_records ' + + '(session_id, user_id, device_id, duration_seconds, mode, result_summary, sync_status, synced_at, started_at, ended_at, created_at, updated_at) ' + + 'VALUES (?, ?, ?, ?, ?, ?, 2, NOW(), ?, ?, NOW(), NOW())', + [ + sessionId, + user.id, + body.device_id, + body.duration_seconds || null, + body.mode || null, + body.result_summary || null, + body.started_at, + body.ended_at + ], + dbConfig + ) + + return RESPONSE.success({ session_id: sessionId }) +} + +async function listRecords(user, body) { + var dbConfig = getDbConfig() + var limit = parseInt(body.limit) || 20 + var offset = parseInt(body.offset) || 0 + + var records = await db.query( + 'SELECT * FROM treatment_records WHERE user_id = ? ORDER BY started_at DESC LIMIT ? OFFSET ?', + [user.id, limit, offset], + dbConfig + ) + + return RESPONSE.success({ list: records }) +} + +async function getRecordDetail(user, body) { + if (!body.record_id) { + return RESPONSE.error(ERROR_CODES.PARAM_ERROR.code, '缺少记录ID') + } + + var dbConfig = getDbConfig() + + var records = await db.query( + 'SELECT * FROM treatment_records WHERE id = ? AND user_id = ? LIMIT 1', + [body.record_id, user.id], + dbConfig + ) + + if (records.length === 0) { + return RESPONSE.error(ERROR_CODES.NOT_FOUND.code, ERROR_CODES.NOT_FOUND.message) + } + + return RESPONSE.success(records[0]) +} + +function extractToken(event) { + var header = event.headers || {} + var auth = header['Authorization'] || header['authorization'] || '' + if (auth.startsWith('Bearer ')) { + return auth.substring(7) + } + return null +} + +async function verifyToken(token) { + return { id: 1, openid: 'placeholder' } +} + +function parseBody(event) { + if (event.body) { + try { + return JSON.parse(event.body) + } catch (e) { + return {} + } + } + return event.queryString || {} +} + +function getDbConfig() { + return { + host: process.env.DB_HOST || 'localhost', + port: parseInt(process.env.DB_PORT || '3306'), + user: process.env.DB_USER || 'root', + password: process.env.DB_PASSWORD || '', + database: process.env.DB_NAME || 'hox' + } +} diff --git a/cloud/functions/subscription/index.js b/cloud/functions/subscription/index.js new file mode 100644 index 0000000..96a4f76 --- /dev/null +++ b/cloud/functions/subscription/index.js @@ -0,0 +1,138 @@ +var db = require('../../common/db') +var auth = require('../../common/auth') +var log = require('../../common/log') +var RESPONSE = require('../../common/response').RESPONSE +var ERROR_CODES = require('../../common/response').ERROR_CODES + +exports.main_handler = async function (event) { + try { + var method = event.httpMethod || 'GET' + var path = event.path || '' + var body = parseBody(event) + + if (method === 'GET' && path === '/api/v1/subscription') { + return await getSubscription(event) + } + if (method === 'POST' && path === '/api/v1/subscription/purchase') { + return await purchaseSubscription(event, body) + } + if (method === 'POST' && path === '/api/v1/subscription/verify') { + return await verifyPayment(event, body) + } + + return RESPONSE.error(ERROR_CODES.PARAM_ERROR.code, 'unknown route') + } catch (err) { + console.error('subscription error:', err) + return RESPONSE.error(ERROR_CODES.SERVER_ERROR.code, err.message) + } +} + +async function getSubscription(event) { + var user = auth.extractUser(event) + if (!user) return RESPONSE.error(ERROR_CODES.INVALID_TOKEN.code, ERROR_CODES.INVALID_TOKEN.message) + + var subs = await db.query( + 'SELECT id, type, plan, status, trial_used, started_at AS start_time, expired_at AS expire_time ' + + 'FROM subscriptions ' + + 'WHERE user_id = ? AND status = 1 AND expired_at > NOW() ' + + 'ORDER BY expired_at DESC LIMIT 1', + [user.user_id] + ) + + if (subs.length === 0) { + return RESPONSE.success({ + status: 0, + plan: null, + start_time: null, + expire_time: null, + remaining_days: 0 + }) + } + + var sub = subs[0] + var remainingMs = new Date(sub.expire_time) - new Date() + var remainingDays = Math.ceil(remainingMs / 86400000) + + return RESPONSE.success({ + status: sub.type === 1 ? 1 : 2, + plan: sub.plan, + start_time: sub.start_time, + expire_time: sub.expire_time, + remaining_days: remainingDays + }) +} + +async function purchaseSubscription(event, body) { + var user = auth.extractUser(event) + if (!user) return RESPONSE.error(ERROR_CODES.INVALID_TOKEN.code, ERROR_CODES.INVALID_TOKEN.message) + + if (!body.plan) { + return RESPONSE.error(ERROR_CODES.PARAM_ERROR.code, 'missing plan') + } + + var planDays = { monthly: 30, quarterly: 90, yearly: 365 } + var days = planDays[body.plan] || 30 + var orderId = 'ORD' + Date.now() + Math.random().toString(36).substring(2, 8) + + return RESPONSE.success({ + order_id: orderId, + payment_params: { + plan: body.plan, + days: days, + amount: 0, + channel: body.payment_method || 'wechat' + } + }) +} + +async function verifyPayment(event, body) { + var user = auth.extractUser(event) + if (!user) return RESPONSE.error(ERROR_CODES.INVALID_TOKEN.code, ERROR_CODES.INVALID_TOKEN.message) + + if (!body.order_id) { + return RESPONSE.error(ERROR_CODES.PARAM_ERROR.code, 'missing order_id') + } + + var planDays = { monthly: 30, quarterly: 90, yearly: 365 } + + var currentSub = await db.query( + 'SELECT id, expired_at FROM subscriptions WHERE user_id = ? AND status = 1 AND expired_at > NOW() ORDER BY expired_at DESC LIMIT 1', + [user.user_id] + ) + + var startDate = new Date() + if (currentSub.length > 0 && new Date(currentSub[0].expired_at) > startDate) { + startDate = new Date(currentSub[0].expired_at) + } + + var plan = body.plan || 'monthly' + var days = planDays[plan] || 30 + var expiredAt = new Date(startDate.getTime() + days * 86400000) + + await db.insert( + 'INSERT INTO subscriptions (user_id, type, plan, status, started_at, expired_at, source, source_id) VALUES (?, 2, ?, 1, ?, ?, ?, ?)', + [user.user_id, plan, startDate.toISOString().slice(0, 19).replace('T', ' '), expiredAt.toISOString().slice(0, 19).replace('T', ' '), 'purchase', body.order_id] + ) + + await log.writeLog({ + operator_type: 2, + operator_id: user.user_id, + target_type: 'subscription', + target_id: body.order_id, + action: 'purchase', + remark: 'plan=' + plan + ',days=' + days + }) + + return RESPONSE.success({ + status: 2, + plan: plan, + expire_time: expiredAt.toISOString().slice(0, 19).replace('T', ' ') + }) +} + +function parseBody(event) { + if (event.body) { + try { return JSON.parse(event.body) } catch (e) { return {} } + } + return event.queryStringParameters || {} +} diff --git a/cloud/functions/treatment/index.js b/cloud/functions/treatment/index.js new file mode 100644 index 0000000..91fe8ca --- /dev/null +++ b/cloud/functions/treatment/index.js @@ -0,0 +1,130 @@ +var db = require('../../common/db') +var auth = require('../../common/auth') +var RESPONSE = require('../../common/response').RESPONSE +var ERROR_CODES = require('../../common/response').ERROR_CODES + +exports.main_handler = async function (event) { + try { + var method = event.httpMethod || 'GET' + var path = event.path || '' + var body = parseBody(event) + + if (method === 'POST' && path === '/api/v1/treatment/sync') { + return await syncRecord(event, body) + } + if (method === 'GET' && path === '/api/v1/treatment/history') { + return await getHistory(event, body) + } + + return RESPONSE.error(ERROR_CODES.PARAM_ERROR.code, 'unknown route') + } catch (err) { + console.error('treatment error:', err) + return RESPONSE.error(ERROR_CODES.SERVER_ERROR.code, err.message) + } +} + +async function syncRecord(event, body) { + var user = auth.extractUser(event) + if (!user) return RESPONSE.error(ERROR_CODES.INVALID_TOKEN.code, ERROR_CODES.INVALID_TOKEN.message) + + if (!body.session_id || !body.device_id) { + return RESPONSE.error(ERROR_CODES.PARAM_ERROR.code, 'missing session_id or device_id') + } + + var devices = await db.query( + 'SELECT id FROM devices WHERE device_id = ? AND deleted_at IS NULL LIMIT 1', + [body.device_id] + ) + if (devices.length === 0) { + return RESPONSE.error(ERROR_CODES.DEVICE_NOT_FOUND.code, ERROR_CODES.DEVICE_NOT_FOUND.message) + } + var devicePkId = devices[0].id + + var existingSession = await db.query( + 'SELECT id FROM sessions WHERE session_id = ? LIMIT 1', + [body.session_id] + ) + + if (existingSession.length === 0) { + await db.insert( + 'INSERT INTO sessions (session_id, user_id, device_id, status, started_at, ended_at) VALUES (?, ?, ?, 2, ?, ?)', + [ + body.session_id, + user.user_id, + devicePkId, + body.start_time ? new Date(body.start_time).toISOString().slice(0, 19).replace('T', ' ') : null, + body.end_time ? new Date(body.end_time).toISOString().slice(0, 19).replace('T', ' ') : null + ] + ) + } + + var existingRecord = await db.query( + 'SELECT id FROM treatment_records WHERE session_id = ? LIMIT 1', + [body.session_id] + ) + + if (existingRecord.length > 0) { + return RESPONSE.success({ record_id: existingRecord[0].id }) + } + + var result = await db.insert( + 'INSERT INTO treatment_records (session_id, user_id, device_id, regions, total_duration_ms, mode, avg_pd, sync_status, synced_at, started_at, ended_at) ' + + 'VALUES (?, ?, ?, ?, ?, ?, ?, 2, NOW(), ?, ?)', + [ + body.session_id, + user.user_id, + devicePkId, + body.regions || null, + body.total_duration_ms || null, + body.mode !== undefined ? body.mode : null, + body.avg_pd || null, + body.start_time ? new Date(body.start_time).toISOString().slice(0, 19).replace('T', ' ') : null, + body.end_time ? new Date(body.end_time).toISOString().slice(0, 19).replace('T', ' ') : null + ] + ) + + if (body.pd_data && Array.isArray(body.pd_data)) { + for (var i = 0; i < body.pd_data.length; i++) { + var pd = body.pd_data[i] + await db.insert( + 'INSERT INTO pd_data (session_id, device_id, region_name, pd_value, recorded_at) VALUES (?, ?, ?, ?, ?)', + [body.session_id, devicePkId, pd.name, pd.pd || null, new Date().toISOString().slice(0, 19).replace('T', ' ')] + ) + } + } + + return RESPONSE.success({ record_id: result.insertId }) +} + +async function getHistory(event, body) { + var user = auth.extractUser(event) + if (!user) return RESPONSE.error(ERROR_CODES.INVALID_TOKEN.code, ERROR_CODES.INVALID_TOKEN.message) + + var page = parseInt(body.page) || 1 + var pageSize = parseInt(body.page_size) || 20 + var offset = (page - 1) * pageSize + + var totalResult = await db.query( + 'SELECT COUNT(*) AS cnt FROM treatment_records WHERE user_id = ?', + [user.user_id] + ) + var total = totalResult[0].cnt + + var records = await db.query( + 'SELECT tr.id AS record_id, tr.session_id, tr.regions, tr.total_duration_ms, tr.mode, tr.avg_pd, tr.started_at AS start_time, tr.ended_at AS end_time, d.device_id, d.device_name ' + + 'FROM treatment_records tr ' + + 'JOIN devices d ON tr.device_id = d.id ' + + 'WHERE tr.user_id = ? ' + + 'ORDER BY tr.started_at DESC LIMIT ? OFFSET ?', + [user.user_id, pageSize, offset] + ) + + return RESPONSE.paginate(records, total, page, pageSize) +} + +function parseBody(event) { + if (event.body) { + try { return JSON.parse(event.body) } catch (e) { return {} } + } + return event.queryStringParameters || {} +} diff --git a/cloud/functions/user/index.js b/cloud/functions/user/index.js new file mode 100644 index 0000000..8eac79e --- /dev/null +++ b/cloud/functions/user/index.js @@ -0,0 +1,74 @@ +var db = require('../../common/db') +var auth = require('../../common/auth') +var RESPONSE = require('../../common/response').RESPONSE +var ERROR_CODES = require('../../common/response').ERROR_CODES + +exports.main_handler = async function (event) { + try { + var method = event.httpMethod || 'GET' + var path = event.path || '' + var body = parseBody(event) + + if (method === 'GET' && path === '/api/v1/user/profile') { + return await getProfile(event) + } + if (method === 'PUT' && path === '/api/v1/user/profile') { + return await updateProfile(event, body) + } + + return RESPONSE.error(ERROR_CODES.PARAM_ERROR.code, 'unknown route') + } catch (err) { + console.error('user error:', err) + return RESPONSE.error(ERROR_CODES.SERVER_ERROR.code, err.message) + } +} + +async function getProfile(event) { + var user = auth.extractUser(event) + if (!user) return RESPONSE.error(ERROR_CODES.INVALID_TOKEN.code, ERROR_CODES.INVALID_TOKEN.message) + + var users = await db.query( + 'SELECT id AS user_id, nickname, avatar_url AS avatar, phone, status FROM users WHERE id = ? AND deleted_at IS NULL LIMIT 1', + [user.user_id] + ) + if (users.length === 0) { + return RESPONSE.error(ERROR_CODES.USER_NOT_FOUND.code, ERROR_CODES.USER_NOT_FOUND.message) + } + + var profile = users[0] + + var bindings = await db.query( + 'SELECT COUNT(*) AS cnt FROM bindings WHERE user_id = ? AND status = 1', + [user.user_id] + ) + var bindTime = await db.query( + 'SELECT bound_at AS bind_time FROM bindings WHERE user_id = ? AND status = 1 ORDER BY bound_at ASC LIMIT 1', + [user.user_id] + ) + + profile.device_count = bindings[0].cnt + profile.bind_time = bindTime.length > 0 ? bindTime[0].bind_time : null + + return RESPONSE.success(profile) +} + +async function updateProfile(event, body) { + var user = auth.extractUser(event) + if (!user) return RESPONSE.error(ERROR_CODES.INVALID_TOKEN.code, ERROR_CODES.INVALID_TOKEN.message) + + if (body.nickname) { + await db.update( + 'UPDATE users SET nickname = ? WHERE id = ?', + [body.nickname, user.user_id] + ) + } + + return RESPONSE.success({ message: 'success' }) +} + +function parseBody(event) { + if (event.body) { + try { return JSON.parse(event.body) } catch (e) { return {} } + } + return event.queryStringParameters || {} +} diff --git a/cloud/package.json b/cloud/package.json new file mode 100644 index 0000000..65aca4d --- /dev/null +++ b/cloud/package.json @@ -0,0 +1,14 @@ +{ + "name": "hox-cloud-functions", + "version": "1.0.0", + "description": "光子美容仪云函数服务", + "main": "index.js", + "dependencies": { + "tencentcloud-sdk-nodejs": "^4.0.0", + "mysql2": "^3.0.0", + "ioredis": "^5.0.0", + "jsonwebtoken": "^9.0.0", + "bcryptjs": "^2.4.0", + "uuid": "^9.0.0" + } +} diff --git a/miniprogram/app.js b/miniprogram/app.js new file mode 100644 index 0000000..6341b2e --- /dev/null +++ b/miniprogram/app.js @@ -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 + }) + }) + } +}) diff --git a/miniprogram/app.json b/miniprogram/app.json new file mode 100644 index 0000000..c3bb707 --- /dev/null +++ b/miniprogram/app.json @@ -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" +} diff --git a/miniprogram/app.wxss b/miniprogram/app.wxss new file mode 100644 index 0000000..d898461 --- /dev/null +++ b/miniprogram/app.wxss @@ -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; +} diff --git a/miniprogram/pages/auto-scan/auto-scan.js b/miniprogram/pages/auto-scan/auto-scan.js new file mode 100644 index 0000000..b5309ed --- /dev/null +++ b/miniprogram/pages/auto-scan/auto-scan.js @@ -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' }) + } +}) diff --git a/miniprogram/pages/auto-scan/auto-scan.json b/miniprogram/pages/auto-scan/auto-scan.json new file mode 100644 index 0000000..cad0ea6 --- /dev/null +++ b/miniprogram/pages/auto-scan/auto-scan.json @@ -0,0 +1,3 @@ +{ + "navigationBarTitleText": "自动扫描" +} diff --git a/miniprogram/pages/auto-scan/auto-scan.wxml b/miniprogram/pages/auto-scan/auto-scan.wxml new file mode 100644 index 0000000..0e2f834 --- /dev/null +++ b/miniprogram/pages/auto-scan/auto-scan.wxml @@ -0,0 +1,19 @@ + + + {{scanning ? '面部扫描中' : '扫描完成'}} + + {{scanning ? '请保持设备贴合面部' : ''}} + + + + 扫描结果 + + {{item.region}} + {{item.pd}} + + + + + 设置护理参数 + + diff --git a/miniprogram/pages/auto-scan/auto-scan.wxss b/miniprogram/pages/auto-scan/auto-scan.wxss new file mode 100644 index 0000000..c0b7d16 --- /dev/null +++ b/miniprogram/pages/auto-scan/auto-scan.wxss @@ -0,0 +1,8 @@ +.scan-result-item { + padding: 16rpx 0; + border-bottom: 1rpx solid #f0f0f0; +} + +.scan-result-item:last-child { + border-bottom: none; +} diff --git a/miniprogram/pages/bind-success/bind-success.js b/miniprogram/pages/bind-success/bind-success.js new file mode 100644 index 0000000..868c160 --- /dev/null +++ b/miniprogram/pages/bind-success/bind-success.js @@ -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' }) + } +}) diff --git a/miniprogram/pages/bind-success/bind-success.json b/miniprogram/pages/bind-success/bind-success.json new file mode 100644 index 0000000..189c71a --- /dev/null +++ b/miniprogram/pages/bind-success/bind-success.json @@ -0,0 +1,3 @@ +{ + "navigationBarTitleText": "绑定成功" +} diff --git a/miniprogram/pages/bind-success/bind-success.wxml b/miniprogram/pages/bind-success/bind-success.wxml new file mode 100644 index 0000000..5ddf7c4 --- /dev/null +++ b/miniprogram/pages/bind-success/bind-success.wxml @@ -0,0 +1,9 @@ + + + + 绑定成功 + 设备已成功绑定到您的账号 + 已自动发放 {{trialDays}} 天试用 + 开始使用 + + diff --git a/miniprogram/pages/bind-success/bind-success.wxss b/miniprogram/pages/bind-success/bind-success.wxss new file mode 100644 index 0000000..95f767c --- /dev/null +++ b/miniprogram/pages/bind-success/bind-success.wxss @@ -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; +} diff --git a/miniprogram/pages/ble-connect/ble-connect.js b/miniprogram/pages/ble-connect/ble-connect.js new file mode 100644 index 0000000..45ae6ff --- /dev/null +++ b/miniprogram/pages/ble-connect/ble-connect.js @@ -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() + } +}) diff --git a/miniprogram/pages/ble-connect/ble-connect.json b/miniprogram/pages/ble-connect/ble-connect.json new file mode 100644 index 0000000..4fa52fe --- /dev/null +++ b/miniprogram/pages/ble-connect/ble-connect.json @@ -0,0 +1,3 @@ +{ + "navigationBarTitleText": "蓝牙连接" +} diff --git a/miniprogram/pages/ble-connect/ble-connect.wxml b/miniprogram/pages/ble-connect/ble-connect.wxml new file mode 100644 index 0000000..ec11568 --- /dev/null +++ b/miniprogram/pages/ble-connect/ble-connect.wxml @@ -0,0 +1,28 @@ + + + + {{state === 'scanning' ? '正在扫描设备...' : state === 'connecting' ? '正在连接设备...' : state === 'binding' ? '正在绑定设备...' : '连接失败'}} + + + + 请确保设备已开机且在附近 + + + 设备ID: {{deviceId}} + + + 正在写入绑定信息... + + + {{error}} + + + + + + + + 重试 + + + diff --git a/miniprogram/pages/ble-connect/ble-connect.wxss b/miniprogram/pages/ble-connect/ble-connect.wxss new file mode 100644 index 0000000..aa1cdd6 --- /dev/null +++ b/miniprogram/pages/ble-connect/ble-connect.wxss @@ -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); } +} diff --git a/miniprogram/pages/discover/discover.js b/miniprogram/pages/discover/discover.js new file mode 100644 index 0000000..ec81d9a --- /dev/null +++ b/miniprogram/pages/discover/discover.js @@ -0,0 +1,8 @@ +Page({ + data: { + articles: [] + }, + + onLoad: function () { + } +}) diff --git a/miniprogram/pages/discover/discover.json b/miniprogram/pages/discover/discover.json new file mode 100644 index 0000000..33c42d0 --- /dev/null +++ b/miniprogram/pages/discover/discover.json @@ -0,0 +1,3 @@ +{ + "navigationBarTitleText": "发现" +} diff --git a/miniprogram/pages/discover/discover.wxml b/miniprogram/pages/discover/discover.wxml new file mode 100644 index 0000000..bdc4757 --- /dev/null +++ b/miniprogram/pages/discover/discover.wxml @@ -0,0 +1,4 @@ + + 发现 + 暂无内容 + diff --git a/miniprogram/pages/discover/discover.wxss b/miniprogram/pages/discover/discover.wxss new file mode 100644 index 0000000..0967ef4 --- /dev/null +++ b/miniprogram/pages/discover/discover.wxss @@ -0,0 +1 @@ +{} diff --git a/miniprogram/pages/history/history.js b/miniprogram/pages/history/history.js new file mode 100644 index 0000000..99734c4 --- /dev/null +++ b/miniprogram/pages/history/history.js @@ -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 }) + }) + } +}) diff --git a/miniprogram/pages/history/history.json b/miniprogram/pages/history/history.json new file mode 100644 index 0000000..0fcc4c7 --- /dev/null +++ b/miniprogram/pages/history/history.json @@ -0,0 +1,3 @@ +{ + "navigationBarTitleText": "护理记录" +} diff --git a/miniprogram/pages/history/history.wxml b/miniprogram/pages/history/history.wxml new file mode 100644 index 0000000..d39d207 --- /dev/null +++ b/miniprogram/pages/history/history.wxml @@ -0,0 +1,26 @@ + + + 加载中... + + + + 📑 + 暂无护理记录 + + + + 共 {{total}} 条记录 + + + + {{item.date_text}} + {{item.duration_text}} + + {{item.region_names}} + + + + 加载更多... + + + diff --git a/miniprogram/pages/history/history.wxss b/miniprogram/pages/history/history.wxss new file mode 100644 index 0000000..e764761 --- /dev/null +++ b/miniprogram/pages/history/history.wxss @@ -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; +} diff --git a/miniprogram/pages/index/index.js b/miniprogram/pages/index/index.js new file mode 100644 index 0000000..ab786f4 --- /dev/null +++ b/miniprogram/pages/index/index.js @@ -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' }) + } + } +}) diff --git a/miniprogram/pages/index/index.json b/miniprogram/pages/index/index.json new file mode 100644 index 0000000..dac4751 --- /dev/null +++ b/miniprogram/pages/index/index.json @@ -0,0 +1,3 @@ +{ + "navigationBarTitleText": "首页" +} diff --git a/miniprogram/pages/index/index.wxml b/miniprogram/pages/index/index.wxml new file mode 100644 index 0000000..69a08f9 --- /dev/null +++ b/miniprogram/pages/index/index.wxml @@ -0,0 +1,52 @@ + + + + 添加设备 + 您还没有绑定设备 + 扫码添加设备 + + + + + + + {{deviceName}} + + + {{connected ? '已连接' : bleState === 'scanning' ? '扫描中...' : bleState === 'connecting' ? '连接中...' : '未连接'}} + + + + + {{battery}}% + + + + + 连接设备 + + + + 设备状态: {{modeState}} + + + + + + + 订阅状态 + + + {{subscription.status === 1 ? '试用中' : '已订阅'}} · 剩余 {{subRemaining}} 天 + + 未订阅 + + + + + + + + 开始护理 + + diff --git a/miniprogram/pages/index/index.wxss b/miniprogram/pages/index/index.wxss new file mode 100644 index 0000000..7c59bfd --- /dev/null +++ b/miniprogram/pages/index/index.wxss @@ -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; +} diff --git a/miniprogram/pages/login/login.js b/miniprogram/pages/login/login.js new file mode 100644 index 0000000..b25ac88 --- /dev/null +++ b/miniprogram/pages/login/login.js @@ -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' }) + }) + } +}) diff --git a/miniprogram/pages/login/login.json b/miniprogram/pages/login/login.json new file mode 100644 index 0000000..eed3cd5 --- /dev/null +++ b/miniprogram/pages/login/login.json @@ -0,0 +1,4 @@ +{ + "navigationBarTitleText": "授权登录", + "navigationStyle": "custom" +} diff --git a/miniprogram/pages/login/login.wxml b/miniprogram/pages/login/login.wxml new file mode 100644 index 0000000..80ad673 --- /dev/null +++ b/miniprogram/pages/login/login.wxml @@ -0,0 +1,16 @@ + + + + H + + 光子美容仪 + 智能护肤 专业管理 + + + + + 登录即同意《用户协议》和《隐私政策》 + + diff --git a/miniprogram/pages/login/login.wxss b/miniprogram/pages/login/login.wxss new file mode 100644 index 0000000..69bdd70 --- /dev/null +++ b/miniprogram/pages/login/login.wxss @@ -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; +} diff --git a/miniprogram/pages/profile/profile.js b/miniprogram/pages/profile/profile.js new file mode 100644 index 0000000..7b1e71e --- /dev/null +++ b/miniprogram/pages/profile/profile.js @@ -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' }) + } + } + }) + } +}) diff --git a/miniprogram/pages/profile/profile.json b/miniprogram/pages/profile/profile.json new file mode 100644 index 0000000..4ec55be --- /dev/null +++ b/miniprogram/pages/profile/profile.json @@ -0,0 +1,3 @@ +{ + "navigationBarTitleText": "我的" +} diff --git a/miniprogram/pages/profile/profile.wxml b/miniprogram/pages/profile/profile.wxml new file mode 100644 index 0000000..b4357db --- /dev/null +++ b/miniprogram/pages/profile/profile.wxml @@ -0,0 +1,43 @@ + + + + + {{(userInfo && userInfo.nickname ? userInfo.nickname[0] : '?')}} + + + + + + + 设备管理 + + {{deviceCount}} 台设备 + + + + + + 订阅管理 + + + {{subscription && subscription.status > 0 ? '剩余 ' + subRemaining + ' 天' : '未订阅'}} + + + + + + + 护理记录 + + + + + + + 退出登录 + + + diff --git a/miniprogram/pages/profile/profile.wxss b/miniprogram/pages/profile/profile.wxss new file mode 100644 index 0000000..617b3a2 --- /dev/null +++ b/miniprogram/pages/profile/profile.wxss @@ -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; +} diff --git a/miniprogram/pages/scan/scan.js b/miniprogram/pages/scan/scan.js new file mode 100644 index 0000000..34bdd21 --- /dev/null +++ b/miniprogram/pages/scan/scan.js @@ -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 || '绑定失败' + }) + }) + } +}) diff --git a/miniprogram/pages/scan/scan.json b/miniprogram/pages/scan/scan.json new file mode 100644 index 0000000..ebd6c2f --- /dev/null +++ b/miniprogram/pages/scan/scan.json @@ -0,0 +1,3 @@ +{ + "navigationBarTitleText": "扫码绑定" +} diff --git a/miniprogram/pages/scan/scan.wxml b/miniprogram/pages/scan/scan.wxml new file mode 100644 index 0000000..d8fa717 --- /dev/null +++ b/miniprogram/pages/scan/scan.wxml @@ -0,0 +1,29 @@ + + + 绑定新设备 + 请扫描设备底部或包装盒上的二维码 + + {{scanning ? '扫描中...' : '扫描二维码'}} + + + + + {{error}} + + + + 绑定步骤 + + 1 + 扫描设备二维码获取设备ID + + + 2 + 打开蓝牙连接设备 + + + 3 + 绑定成功,开始使用 + + + diff --git a/miniprogram/pages/scan/scan.wxss b/miniprogram/pages/scan/scan.wxss new file mode 100644 index 0000000..75c8b29 --- /dev/null +++ b/miniprogram/pages/scan/scan.wxss @@ -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; +} diff --git a/miniprogram/pages/subscribe-plans/subscribe-plans.js b/miniprogram/pages/subscribe-plans/subscribe-plans.js new file mode 100644 index 0000000..9c873ae --- /dev/null +++ b/miniprogram/pages/subscribe-plans/subscribe-plans.js @@ -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' }) + }) + } +}) diff --git a/miniprogram/pages/subscribe-plans/subscribe-plans.json b/miniprogram/pages/subscribe-plans/subscribe-plans.json new file mode 100644 index 0000000..89929bc --- /dev/null +++ b/miniprogram/pages/subscribe-plans/subscribe-plans.json @@ -0,0 +1,3 @@ +{ + "navigationBarTitleText": "订阅套餐" +} diff --git a/miniprogram/pages/subscribe-plans/subscribe-plans.wxml b/miniprogram/pages/subscribe-plans/subscribe-plans.wxml new file mode 100644 index 0000000..9babf51 --- /dev/null +++ b/miniprogram/pages/subscribe-plans/subscribe-plans.wxml @@ -0,0 +1,15 @@ + + + {{item.name}} + {{item.price}} + {{item.desc}} + + + + + {{purchasing ? '处理中...' : '立即订阅'}} + + + diff --git a/miniprogram/pages/subscribe-plans/subscribe-plans.wxss b/miniprogram/pages/subscribe-plans/subscribe-plans.wxss new file mode 100644 index 0000000..a36182a --- /dev/null +++ b/miniprogram/pages/subscribe-plans/subscribe-plans.wxss @@ -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; +} diff --git a/miniprogram/pages/subscribe-prompt/subscribe-prompt.js b/miniprogram/pages/subscribe-prompt/subscribe-prompt.js new file mode 100644 index 0000000..1b3dcc9 --- /dev/null +++ b/miniprogram/pages/subscribe-prompt/subscribe-prompt.js @@ -0,0 +1,11 @@ +Page({ + data: {}, + + onViewPlans: function () { + wx.navigateTo({ url: '/pages/subscribe-plans/subscribe-plans' }) + }, + + onBack: function () { + wx.switchTab({ url: '/pages/index/index' }) + } +}) diff --git a/miniprogram/pages/subscribe-prompt/subscribe-prompt.json b/miniprogram/pages/subscribe-prompt/subscribe-prompt.json new file mode 100644 index 0000000..ebf5a93 --- /dev/null +++ b/miniprogram/pages/subscribe-prompt/subscribe-prompt.json @@ -0,0 +1,3 @@ +{ + "navigationBarTitleText": "订阅提示" +} diff --git a/miniprogram/pages/subscribe-prompt/subscribe-prompt.wxml b/miniprogram/pages/subscribe-prompt/subscribe-prompt.wxml new file mode 100644 index 0000000..d8bd786 --- /dev/null +++ b/miniprogram/pages/subscribe-prompt/subscribe-prompt.wxml @@ -0,0 +1,9 @@ + + + 🔒 + 需要订阅 + 您的试用已到期,请订阅后继续使用 + 查看套餐 + 返回首页 + + diff --git a/miniprogram/pages/subscribe-prompt/subscribe-prompt.wxss b/miniprogram/pages/subscribe-prompt/subscribe-prompt.wxss new file mode 100644 index 0000000..98f59cb --- /dev/null +++ b/miniprogram/pages/subscribe-prompt/subscribe-prompt.wxss @@ -0,0 +1,4 @@ +.lock-icon { + font-size: 80rpx; + margin: 40rpx auto; +} diff --git a/miniprogram/pages/subscribe-success/subscribe-success.js b/miniprogram/pages/subscribe-success/subscribe-success.js new file mode 100644 index 0000000..9d2578c --- /dev/null +++ b/miniprogram/pages/subscribe-success/subscribe-success.js @@ -0,0 +1,7 @@ +Page({ + data: {}, + + onBackHome: function () { + wx.switchTab({ url: '/pages/index/index' }) + } +}) diff --git a/miniprogram/pages/subscribe-success/subscribe-success.json b/miniprogram/pages/subscribe-success/subscribe-success.json new file mode 100644 index 0000000..9bba5d3 --- /dev/null +++ b/miniprogram/pages/subscribe-success/subscribe-success.json @@ -0,0 +1,3 @@ +{ + "navigationBarTitleText": "订阅成功" +} diff --git a/miniprogram/pages/subscribe-success/subscribe-success.wxml b/miniprogram/pages/subscribe-success/subscribe-success.wxml new file mode 100644 index 0000000..be73539 --- /dev/null +++ b/miniprogram/pages/subscribe-success/subscribe-success.wxml @@ -0,0 +1,8 @@ + + + + 订阅成功 + 您已成功订阅,现在可以开始使用 + 返回首页 + + diff --git a/miniprogram/pages/subscribe-success/subscribe-success.wxss b/miniprogram/pages/subscribe-success/subscribe-success.wxss new file mode 100644 index 0000000..3156779 --- /dev/null +++ b/miniprogram/pages/subscribe-success/subscribe-success.wxss @@ -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; +} diff --git a/miniprogram/pages/treating/treating.js b/miniprogram/pages/treating/treating.js new file mode 100644 index 0000000..e59e2f9 --- /dev/null +++ b/miniprogram/pages/treating/treating.js @@ -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() + }) + } + } + }) + } +}) diff --git a/miniprogram/pages/treating/treating.json b/miniprogram/pages/treating/treating.json new file mode 100644 index 0000000..402296a --- /dev/null +++ b/miniprogram/pages/treating/treating.json @@ -0,0 +1,3 @@ +{ + "navigationBarTitleText": "护理中" +} diff --git a/miniprogram/pages/treating/treating.wxml b/miniprogram/pages/treating/treating.wxml new file mode 100644 index 0000000..0e1cd43 --- /dev/null +++ b/miniprogram/pages/treating/treating.wxml @@ -0,0 +1,33 @@ + + + + {{remainingText}} + + + + + + + + + 波长 + {{wavelength === 1 ? '红外 850nm' : wavelength === 2 ? '红光 630nm' : wavelength === 3 ? '紫光 405nm' : '黄光 590nm'}} + + + 模式 + {{mode === 1 ? '智能模式' : '普通模式'}} + + + 电量 + {{battery}}% + + + 温度 + {{temperature}}°C + + + + + 结束护理 + + diff --git a/miniprogram/pages/treating/treating.wxss b/miniprogram/pages/treating/treating.wxss new file mode 100644 index 0000000..e6b6cdc --- /dev/null +++ b/miniprogram/pages/treating/treating.wxss @@ -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; +} diff --git a/miniprogram/pages/treatment-done/treatment-done.js b/miniprogram/pages/treatment-done/treatment-done.js new file mode 100644 index 0000000..01794c4 --- /dev/null +++ b/miniprogram/pages/treatment-done/treatment-done.js @@ -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' }) + } +}) diff --git a/miniprogram/pages/treatment-done/treatment-done.json b/miniprogram/pages/treatment-done/treatment-done.json new file mode 100644 index 0000000..0e1bd13 --- /dev/null +++ b/miniprogram/pages/treatment-done/treatment-done.json @@ -0,0 +1,3 @@ +{ + "navigationBarTitleText": "护理完成" +} diff --git a/miniprogram/pages/treatment-done/treatment-done.wxml b/miniprogram/pages/treatment-done/treatment-done.wxml new file mode 100644 index 0000000..10900d8 --- /dev/null +++ b/miniprogram/pages/treatment-done/treatment-done.wxml @@ -0,0 +1,34 @@ + + + + 护理完成 + + + + + 护理时长 + {{durationText}} + + + 护理区域 + {{regionNames.join('、')}} + + + 平均光功率密度 + {{avgPd}} + + + + + + + 正在同步记录... + + 记录已同步 + 同步失败,可稍后在护理记录中重试 + + + + 返回首页 + + diff --git a/miniprogram/pages/treatment-done/treatment-done.wxss b/miniprogram/pages/treatment-done/treatment-done.wxss new file mode 100644 index 0000000..dcce116 --- /dev/null +++ b/miniprogram/pages/treatment-done/treatment-done.wxss @@ -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); } +} diff --git a/miniprogram/pages/treatment-setup/treatment-setup.js b/miniprogram/pages/treatment-setup/treatment-setup.js new file mode 100644 index 0000000..93b77dd --- /dev/null +++ b/miniprogram/pages/treatment-setup/treatment-setup.js @@ -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 || '启动失败' }) + }) + } +}) diff --git a/miniprogram/pages/treatment-setup/treatment-setup.json b/miniprogram/pages/treatment-setup/treatment-setup.json new file mode 100644 index 0000000..2844a1a --- /dev/null +++ b/miniprogram/pages/treatment-setup/treatment-setup.json @@ -0,0 +1,3 @@ +{ + "navigationBarTitleText": "护理设置" +} diff --git a/miniprogram/pages/treatment-setup/treatment-setup.wxml b/miniprogram/pages/treatment-setup/treatment-setup.wxml new file mode 100644 index 0000000..b655677 --- /dev/null +++ b/miniprogram/pages/treatment-setup/treatment-setup.wxml @@ -0,0 +1,62 @@ + + + 护理区域 + + + {{item.name}} + + + 全选 + + + + 波长选择 + + + {{item.name}} + {{item.desc}} + + + + + + 亮度: {{brightness}} + + + + + 护理时长 + + + {{item.label}} + + + + + + 护理模式 + + + {{item.name}} + + + + + + {{error}} + + + + + {{submitting ? '启动中...' : '开始护理'}} + + + diff --git a/miniprogram/pages/treatment-setup/treatment-setup.wxss b/miniprogram/pages/treatment-setup/treatment-setup.wxss new file mode 100644 index 0000000..28ef2d3 --- /dev/null +++ b/miniprogram/pages/treatment-setup/treatment-setup.wxss @@ -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; +} diff --git a/miniprogram/pages/wear-check/wear-check.js b/miniprogram/pages/wear-check/wear-check.js new file mode 100644 index 0000000..f4f18b7 --- /dev/null +++ b/miniprogram/pages/wear-check/wear-check.js @@ -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() + } +}) diff --git a/miniprogram/pages/wear-check/wear-check.json b/miniprogram/pages/wear-check/wear-check.json new file mode 100644 index 0000000..38183a6 --- /dev/null +++ b/miniprogram/pages/wear-check/wear-check.json @@ -0,0 +1,3 @@ +{ + "navigationBarTitleText": "确认佩戴" +} diff --git a/miniprogram/pages/wear-check/wear-check.wxml b/miniprogram/pages/wear-check/wear-check.wxml new file mode 100644 index 0000000..8efc439 --- /dev/null +++ b/miniprogram/pages/wear-check/wear-check.wxml @@ -0,0 +1,27 @@ + + + 确认佩戴 + + + + + 正在检测佩戴状态... + + + 请将设备贴合面部,确保佩戴稳固 + + + + + + 佩戴确认成功 + 设置护理参数 + + + + + {{error}} + 重新检测 + + + diff --git a/miniprogram/pages/wear-check/wear-check.wxss b/miniprogram/pages/wear-check/wear-check.wxss new file mode 100644 index 0000000..e4979a4 --- /dev/null +++ b/miniprogram/pages/wear-check/wear-check.wxss @@ -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; +} diff --git a/miniprogram/project.config.json b/miniprogram/project.config.json new file mode 100644 index 0000000..3321221 --- /dev/null +++ b/miniprogram/project.config.json @@ -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": {} +} \ No newline at end of file diff --git a/miniprogram/project.private.config.json b/miniprogram/project.private.config.json new file mode 100644 index 0000000..62a0c2a --- /dev/null +++ b/miniprogram/project.private.config.json @@ -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 + } +} \ No newline at end of file diff --git a/miniprogram/services/ble.js b/miniprogram/services/ble.js new file mode 100644 index 0000000..e6ab803 --- /dev/null +++ b/miniprogram/services/ble.js @@ -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 +} diff --git a/miniprogram/services/mqtt.js b/miniprogram/services/mqtt.js new file mode 100644 index 0000000..1e18e26 --- /dev/null +++ b/miniprogram/services/mqtt.js @@ -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 +} diff --git a/miniprogram/sitemap.json b/miniprogram/sitemap.json new file mode 100644 index 0000000..9230ad8 --- /dev/null +++ b/miniprogram/sitemap.json @@ -0,0 +1,9 @@ +{ + "desc": "关于本文件的更多信息,请参考文档 https://developers.weixin.qq.com/miniprogram/dev/framework/sitemap.html", + "rules": [ + { + "action": "allow", + "page": "*" + } + ] +} diff --git a/miniprogram/utils/mock.js b/miniprogram/utils/mock.js new file mode 100644 index 0000000..1412467 --- /dev/null +++ b/miniprogram/utils/mock.js @@ -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 +} diff --git a/miniprogram/utils/request.js b/miniprogram/utils/request.js new file mode 100644 index 0000000..a4e7920 --- /dev/null +++ b/miniprogram/utils/request.js @@ -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 +}