fix: improve UX and fix admin console display issues
Miniprogram: - Add back buttons to all custom-nav pages - Add debug/mock buttons (BLE connect, wear check, treatment) controlled by __DEV__ flag (hidden in prod) Admin console: - Fix log page using nonexistent fields (operator_type, target_type) now correctly reads admin_id/user_id/action/detail from API - Fix subscription date fields (started_at→start_time, expired_at→expire_time) - Wire up subscription detail/extend/renew action buttons - Wire up device detail "查看完整日志" button - Add "创建订阅" button to subscription toolbar - Fix subscription status mapping (2=expired, 3=cancelled) Docs: - Add detailed architecture docs for server, miniprogram, admin console
这个提交包含在:
@@ -95,7 +95,7 @@
|
||||
|
||||
<view class="action-row">
|
||||
<button class="btn-primary" @click="onCommand('query')">发送指令</button>
|
||||
<button class="btn-default">查看完整日志</button>
|
||||
<button class="btn-default" @click="onViewLogs">查看完整日志</button>
|
||||
</view>
|
||||
</AdminLayout>
|
||||
</template>
|
||||
@@ -154,6 +154,9 @@ export default {
|
||||
}
|
||||
})
|
||||
},
|
||||
onViewLogs() {
|
||||
uni.redirectTo({ url: '/pages/log/index?device_id=' + this.deviceId })
|
||||
},
|
||||
onCheckUpdate() {
|
||||
uni.showToast({ title: '正在检查更新...', icon: 'none' })
|
||||
},
|
||||
|
||||
@@ -15,15 +15,15 @@
|
||||
|
||||
<view class="page-card">
|
||||
<view class="log-list">
|
||||
<view class="log-item" v-for="item in logs" :key="item.id">
|
||||
<view class="log-item" v-for="item in logs" :key="item.log_id">
|
||||
<text class="log-time">{{ formatDate(item.created_at) }}</text>
|
||||
<view class="log-content">
|
||||
<text :class="logTypeBadge(item.operator_type)">{{ logTypeText(item.operator_type) }}</text>
|
||||
<text :class="actorBadge(item)">{{ actorLabel(item) }}</text>
|
||||
<text class="log-text">
|
||||
<text class="log-actor" :class="logActorClass(item.operator_type)">{{ item.operator_type === 1 ? '管理员' : '系统' }}</text>
|
||||
对 <text class="log-target">{{ item.target_type }} {{ item.target_id || '' }}</text>
|
||||
执行了 <text class="log-action">{{ item.action }}</text>
|
||||
<text class="log-action">{{ actionLabel(item.action) }}</text>
|
||||
<text class="log-detail" v-if="item.detail"> — {{ item.detail }}</text>
|
||||
</text>
|
||||
<text class="log-meta" v-if="item.ip">IP: {{ item.ip }}</text>
|
||||
</view>
|
||||
</view>
|
||||
</view>
|
||||
@@ -60,6 +60,11 @@ export default {
|
||||
return Math.ceil(this.total / this.pageSize) || 1
|
||||
}
|
||||
},
|
||||
onLoad(options) {
|
||||
if (options && options.device_id) {
|
||||
this.filters.device_id = options.device_id
|
||||
}
|
||||
},
|
||||
onShow() {
|
||||
this.loadLogs()
|
||||
},
|
||||
@@ -84,20 +89,24 @@ export default {
|
||||
this.page = p
|
||||
this.loadLogs()
|
||||
},
|
||||
logTypeText(type) {
|
||||
if (type === 1) return '管理操作'
|
||||
if (type === 2) return '系统事件'
|
||||
return '用户操作'
|
||||
actorLabel(item) {
|
||||
if (item.admin_id) return '管理员 #' + item.admin_id
|
||||
if (item.user_id) return '用户 #' + item.user_id
|
||||
return '系统'
|
||||
},
|
||||
logTypeBadge(type) {
|
||||
if (type === 1) return 'badge badge-pink'
|
||||
if (type === 2) return 'badge badge-blue'
|
||||
return 'badge badge-green'
|
||||
actorBadge(item) {
|
||||
if (item.admin_id) return 'badge badge-pink'
|
||||
if (item.user_id) return 'badge badge-green'
|
||||
return 'badge badge-blue'
|
||||
},
|
||||
logActorClass(type) {
|
||||
if (type === 1) return 'actor-admin'
|
||||
if (type === 2) return 'actor-system'
|
||||
return 'actor-user'
|
||||
actionLabel(action) {
|
||||
var map = {
|
||||
admin_login: '管理员登录', admin_device_create: '创建设备', admin_device_unbind: '后台解绑',
|
||||
admin_device_command: '下发指令', user_register: '用户注册', user_login: '用户登录',
|
||||
device_bind: '设备绑定', device_unbind: '设备解绑', treatment_sync: '护理记录同步',
|
||||
subscription_verify: '订阅核销'
|
||||
}
|
||||
return map[action] || action
|
||||
},
|
||||
formatDate: formatDateUtil,
|
||||
async onExport() {
|
||||
@@ -170,29 +179,18 @@ export default {
|
||||
color: #666;
|
||||
}
|
||||
|
||||
.log-actor {
|
||||
font-weight: 600;
|
||||
}
|
||||
|
||||
.actor-admin {
|
||||
color: #E6508C;
|
||||
}
|
||||
|
||||
.actor-system {
|
||||
color: #1890ff;
|
||||
}
|
||||
|
||||
.actor-user {
|
||||
color: #52c41a;
|
||||
}
|
||||
|
||||
.log-target {
|
||||
color: #333;
|
||||
font-weight: 500;
|
||||
}
|
||||
|
||||
.log-action {
|
||||
color: #333;
|
||||
font-weight: 500;
|
||||
}
|
||||
|
||||
.log-detail {
|
||||
color: #999;
|
||||
}
|
||||
|
||||
.log-meta {
|
||||
font-size: 12px;
|
||||
color: #bbb;
|
||||
margin-top: 2px;
|
||||
}
|
||||
</style>
|
||||
|
||||
@@ -2,6 +2,7 @@
|
||||
<AdminLayout currentPage="/pages/subscription/index">
|
||||
<view class="toolbar">
|
||||
<view class="header-actions">
|
||||
<button class="btn-primary btn-sm" @click="showCreate = true">创建订阅</button>
|
||||
<button class="btn-default btn-sm" @click="onExport">导出报表</button>
|
||||
</view>
|
||||
</view>
|
||||
@@ -53,16 +54,15 @@
|
||||
<text class="t-td flex2">{{ item.nickname || item.user_id }}</text>
|
||||
<text class="t-td flex1">{{ planText(item.plan) }}</text>
|
||||
<text class="t-td flex1">¥{{ item.amount || '-' }}</text>
|
||||
<text class="t-td flex2">{{ formatDate(item.started_at) }}</text>
|
||||
<text class="t-td flex2">{{ formatDate(item.expired_at) }}</text>
|
||||
<text class="t-td flex2">{{ formatDate(item.start_time) }}</text>
|
||||
<text class="t-td flex2">{{ formatDate(item.expire_time) }}</text>
|
||||
<text class="t-td flex1">
|
||||
<text :class="statusBadge(item.status)">{{ statusText(item.status) }}</text>
|
||||
</text>
|
||||
<text class="t-td flex1">
|
||||
<text class="action-link">详情</text>
|
||||
<text class="action-link" v-if="item.status === 1">延期</text>
|
||||
<text class="action-link" v-if="item.status === 2">开通</text>
|
||||
<text class="action-link" v-if="item.status === 3">续费</text>
|
||||
<text class="action-link" @click="onDetail(item)">详情</text>
|
||||
<text class="action-link" v-if="item.status === 1" @click="onExtend(item)">延期</text>
|
||||
<text class="action-link" v-if="item.status === 2 || item.status === 3" @click="onRenew(item)">续费</text>
|
||||
</text>
|
||||
</view>
|
||||
</view>
|
||||
@@ -166,16 +166,49 @@ export default {
|
||||
this.creating = false
|
||||
}
|
||||
},
|
||||
onDetail(item) {
|
||||
if (item.user_id) {
|
||||
uni.navigateTo({ url: '/pages/user-detail/index?user_id=' + item.user_id })
|
||||
}
|
||||
},
|
||||
onExtend(item) {
|
||||
var self = this
|
||||
uni.showModal({
|
||||
title: '延期订阅',
|
||||
content: '为用户 #' + item.user_id + ' 延期 30 天?',
|
||||
success: async function (res) {
|
||||
if (res.confirm) {
|
||||
try {
|
||||
await post('/api/v1/admin/subscriptions', {
|
||||
user_id: String(item.user_id),
|
||||
plan: item.plan || 'monthly',
|
||||
days: 30
|
||||
})
|
||||
uni.showToast({ title: '延期成功', icon: 'success' })
|
||||
self.loadSubscriptions()
|
||||
} catch (e) {
|
||||
uni.showToast({ title: '操作失败', icon: 'none' })
|
||||
}
|
||||
}
|
||||
}
|
||||
})
|
||||
},
|
||||
onRenew(item) {
|
||||
this.createForm.user_id = String(item.user_id || '')
|
||||
this.createForm.plan = item.plan || 'monthly'
|
||||
this.createForm.days = 30
|
||||
this.showCreate = true
|
||||
},
|
||||
planText(plan) {
|
||||
const map = { monthly: '月卡', quarterly: '季卡', yearly: '年卡', trial: '试用' }
|
||||
return map[plan] || plan || '-'
|
||||
},
|
||||
statusText(status) {
|
||||
const map = { 1: '生效中', 2: '试用中', 3: '已过期' }
|
||||
const map = { 1: '生效中', 2: '已过期', 3: '已取消' }
|
||||
return map[status] || '-'
|
||||
},
|
||||
statusBadge(status) {
|
||||
const map = { 1: 'badge badge-success', 2: 'badge badge-warning', 3: 'badge badge-error' }
|
||||
const map = { 1: 'badge badge-success', 2: 'badge badge-error', 3: 'badge badge-default' }
|
||||
return map[status] || 'badge badge-default'
|
||||
},
|
||||
formatDate: formatDateShort,
|
||||
@@ -188,7 +221,7 @@ export default {
|
||||
exportCSV('subscriptions_' + new Date().toISOString().slice(0, 10) + '.csv',
|
||||
['用户', '订阅类型', '订单金额', '开始日期', '到期日期', '状态'],
|
||||
records.map(function (r) {
|
||||
return [r.nickname || r.user_id || '', planMap[r.plan] || r.plan || '', r.amount || '', r.started_at ? r.started_at.slice(0, 10) : '', r.expired_at ? r.expired_at.slice(0, 10) : '', statusMap[r.status] || '']
|
||||
return [r.nickname || r.user_id || '', planMap[r.plan] || r.plan || '', r.amount || '', r.start_time ? String(r.start_time).slice(0, 10) : '', r.expire_time ? String(r.expire_time).slice(0, 10) : '', statusMap[r.status] || '']
|
||||
})
|
||||
)
|
||||
uni.showToast({ title: '导出成功', icon: 'success' })
|
||||
|
||||
@@ -0,0 +1,388 @@
|
||||
# 服务端架构
|
||||
|
||||
## 概述
|
||||
|
||||
服务端是一个运行在腾讯云 SCF(Serverless Cloud Function)上的 Node.js 应用,以 HTTP 函数形式对外暴露 RESTful API。不依赖任何 Web 框架(如 Express/Koa),所有路由和请求处理均为手写实现。
|
||||
|
||||
**技术栈:** Node.js + mysql2 + jsonwebtoken + bcryptjs + cos-nodejs-sdk-v5
|
||||
|
||||
---
|
||||
|
||||
## 目录结构
|
||||
|
||||
```
|
||||
server/
|
||||
├── index.js # 入口:re-export src/index.js
|
||||
├── scf_bootstrap # SCF Web 函数冷启动脚本
|
||||
├── package.json
|
||||
├── scripts/
|
||||
│ ├── init-db.js # 数据库初始化(建表 + 种子数据)
|
||||
│ └── local-server.js # 本地开发 HTTP 服务器
|
||||
├── sql/
|
||||
│ └── schema.sql # 完整 DDL(10 张表)
|
||||
└── src/
|
||||
├── index.js # SCF 入口:导出 main_handler(event, context)
|
||||
├── app.js # 请求分发:解析 → 路由匹配 → 执行 → 响应
|
||||
├── config.js # 环境变量集中配置
|
||||
├── lib/
|
||||
│ ├── auth.js # JWT 签发/验证、bcrypt 密码哈希
|
||||
│ ├── cos.js # 腾讯云 COS 客户端(预签名 URL)
|
||||
│ ├── db.js # MySQL 连接池、query/one/transaction
|
||||
│ ├── log.js # 操作日志写入
|
||||
│ ├── request.js # SCF event → ctx 对象解析
|
||||
│ ├── response.js # ok/fail/http 响应构造
|
||||
│ ├── router.js # 轻量正则路由器
|
||||
│ ├── utils.js # 日期格式化工具
|
||||
│ └── wechat.js # 微信小程序 API(code2Session、手机号)
|
||||
└── routes/
|
||||
├── admin.js # 管理后台 CRUD
|
||||
├── auth.js # 微信登录 + token 刷新
|
||||
├── device.js # 设备绑定/解绑/命令/事件
|
||||
├── firmware.js # 固件管理 + OTA 检查
|
||||
├── subscription.js # 订阅状态/购买/核销
|
||||
├── treatment.js # 护理记录同步/查询
|
||||
└── user.js # 用户信息 CRUD
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## 请求生命周期
|
||||
|
||||
一个请求从进入到返回的完整流程:
|
||||
|
||||
```
|
||||
SCF 触发 / HTTP 请求
|
||||
│
|
||||
▼
|
||||
scf_bootstrap (PORT=9000, node scripts/local-server.js)
|
||||
│
|
||||
▼
|
||||
local-server.js 收集请求体,构造 API Gateway event 对象
|
||||
│
|
||||
▼
|
||||
src/index.js → main_handler(event, context)
|
||||
│
|
||||
▼
|
||||
src/app.js → handle(event)
|
||||
│
|
||||
├── createContext(event) ← 解析 method/path/headers/query/body/ip
|
||||
├── OPTIONS ? → 直接返回 204(CORS 预检)
|
||||
├── /health ? → 返回 { status: 'ok' }
|
||||
├── router.match(method, path)
|
||||
│ ├── 无匹配 → 404 not_found
|
||||
│ └── 有匹配 → 提取 URL 参数,调用 handler(ctx)
|
||||
│ ├── handler 返回值 → http(200, body)
|
||||
│ └── handler 抛异常 → http(500, fail(3001, 'server_error'))
|
||||
│
|
||||
▼
|
||||
SCF API Gateway 响应格式:
|
||||
{
|
||||
isBase64Encoded: false,
|
||||
statusCode: 200,
|
||||
headers: { 'Access-Control-Allow-Origin': '*', ... },
|
||||
body: '{"code":0,"message":"success","data":{...}}'
|
||||
}
|
||||
```
|
||||
|
||||
### 关键设计点
|
||||
|
||||
- **无中间件链**:没有 Koa/Express 那样的中间件栈。鉴权在每个路由处理函数开头手动调用 `requireUser(ctx)` 或 `requireAdmin(ctx)`。
|
||||
- **CORS**:所有响应都带 `Access-Control-Allow-Origin: *`,允许 `Content-Type, Authorization, X-Device-Id, X-App-Version, X-Platform` 请求头。
|
||||
- **路由器**:自研轻量实现,把 `/api/v1/device/:device_id` 这样的路径转换成正则表达式,匹配时提取参数写入 `ctx.params`。
|
||||
|
||||
---
|
||||
|
||||
## 配置结构
|
||||
|
||||
所有配置通过 `dotenv` 从 `.env` 文件加载,集中定义在 `src/config.js`:
|
||||
|
||||
| 配置项 | 环境变量 | 默认值 | 说明 |
|
||||
|--------|---------|--------|------|
|
||||
| `db.host` | `DB_HOST` | - | MySQL 地址 |
|
||||
| `db.port` | `DB_PORT` | 3306 | MySQL 端口 |
|
||||
| `db.user` | `DB_USER` | root | |
|
||||
| `db.password` | `DB_PASSWORD` | - | |
|
||||
| `db.database` | `DB_NAME` | jw_beauty | |
|
||||
| `jwt.secret` | `JWT_SECRET` | dev-user-secret | 用户 token 签名密钥 |
|
||||
| `jwt.adminSecret` | `ADMIN_JWT_SECRET` | dev-admin-secret | 管理员 token 签名密钥 |
|
||||
| `jwt.expiresIn` | - | 7d | token 有效期 |
|
||||
| `cos.secretId` | `TENCENT_SECRET_ID` | - | COS 密钥 |
|
||||
| `cos.secretKey` | `TENCENT_SECRET_KEY` | - | COS 密钥 |
|
||||
| `cos.bucket` | `COS_BUCKET` | jw-bucket-1426323813 | |
|
||||
| `cos.region` | `COS_REGION` | ap-guangzhou | |
|
||||
| `wechat.appid` | `WECHAT_APPID` | - | 小程序 AppID |
|
||||
| `wechat.secret` | `WECHAT_SECRET` | - | 小程序密钥 |
|
||||
|
||||
**生产环境保护**:如果 `NODE_ENV=production` 且 JWT 密钥仍为默认值,进程启动时会直接抛异常,防止带着测试密钥上线。
|
||||
|
||||
---
|
||||
|
||||
## 数据库层
|
||||
|
||||
### 连接方式
|
||||
|
||||
使用 `mysql2/promise`,惰性初始化单例连接池:
|
||||
|
||||
- 连接数上限:5
|
||||
- 命名占位符:`:param_name`(通过 `namedPlaceholders: true` 启用)
|
||||
- 时区:`+08:00`
|
||||
|
||||
### 查询工具
|
||||
|
||||
| 函数 | 说明 |
|
||||
|------|------|
|
||||
| `query(sql, params)` | 执行查询,返回行数组 |
|
||||
| `one(sql, params)` | 执行查询,返回第一行或 `null` |
|
||||
| `transaction(work)` | 获取连接 → BEGIN → 执行 work(conn) → COMMIT/ROLLBACK → 释放 |
|
||||
| `limitClause(pageSize, offset)` | 返回 ` LIMIT N OFFSET M` 字符串片段 |
|
||||
|
||||
### 数据表一览
|
||||
|
||||
共 10 张表,全部 InnoDB + utf8mb4_unicode_ci:
|
||||
|
||||
| 表名 | 用途 | 主键 |
|
||||
|------|------|------|
|
||||
| `users` | 小程序用户 | user_id (自增) |
|
||||
| `devices` | 设备信息 | device_id (字符串) |
|
||||
| `bindings` | 用户-设备绑定关系 | binding_id (自增) |
|
||||
| `subscriptions` | 用户订阅 | subscription_id (自增) |
|
||||
| `treatment_records` | 护理记录 | record_id (自增),session_id 唯一索引 |
|
||||
| `device_events` | 设备事件日志 | event_id (自增) |
|
||||
| `device_commands` | 远程指令队列 | command_id (自增) |
|
||||
| `operation_logs` | 操作审计日志 | log_id (自增) |
|
||||
| `admin_accounts` | 管理员账号 | admin_id (自增) |
|
||||
| `system_settings` | 系统配置键值对 | setting_key (字符串) |
|
||||
| `firmware_files` | 固件文件记录 | firmware_id (自增) |
|
||||
|
||||
### 核心表详细结构
|
||||
|
||||
**users**
|
||||
|
||||
| 字段 | 类型 | 说明 |
|
||||
|------|------|------|
|
||||
| user_id | BIGINT UNSIGNED AUTO_INCREMENT | 主键 |
|
||||
| openid | VARCHAR(64) UNIQUE | 微信 openid |
|
||||
| nickname | VARCHAR(100) | 昵称 |
|
||||
| avatar | VARCHAR(500) | 头像 URL |
|
||||
| phone | VARCHAR(32) | 手机号 |
|
||||
| gender | TINYINT | 0=未知 |
|
||||
| status | TINYINT | 1=正常 |
|
||||
|
||||
**devices**
|
||||
|
||||
| 字段 | 类型 | 说明 |
|
||||
|------|------|------|
|
||||
| device_id | VARCHAR(32) | 主键,设备编号 |
|
||||
| product_id | VARCHAR(64) | 产品型号,默认 HOX_LIGHT_MASK |
|
||||
| device_secret | VARCHAR(128) | 设备密钥 |
|
||||
| firmware_version | VARCHAR(32) | 固件版本 |
|
||||
| status | TINYINT | 1=未激活 2=在线 3=离线 4=禁用 |
|
||||
| battery | TINYINT UNSIGNED | 电量 |
|
||||
| temperature | TINYINT UNSIGNED | 温度 |
|
||||
| last_online_at | DATETIME | 最后在线时间 |
|
||||
|
||||
**bindings**
|
||||
|
||||
| 字段 | 类型 | 说明 |
|
||||
|------|------|------|
|
||||
| binding_id | BIGINT AUTO_INCREMENT | 主键 |
|
||||
| user_id | BIGINT | 外键 → users |
|
||||
| device_id | VARCHAR(32) | 外键 → devices |
|
||||
| bind_token | CHAR(16) | 绑定令牌(16位 hex) |
|
||||
| bind_expires | DATETIME | 令牌过期时间(10 分钟) |
|
||||
| bind_status | TINYINT | 1=已绑定 2=已解绑 3=待确认 |
|
||||
|
||||
**subscriptions**
|
||||
|
||||
| 字段 | 类型 | 说明 |
|
||||
|------|------|------|
|
||||
| subscription_id | BIGINT AUTO_INCREMENT | 主键 |
|
||||
| user_id | BIGINT | 外键 → users |
|
||||
| plan | VARCHAR(32) | trial / monthly / yearly |
|
||||
| status | TINYINT | 1=生效 2=过期 3=取消 |
|
||||
| amount | DECIMAL(10,2) | 金额 |
|
||||
| start_time / expire_time | DATETIME | 有效期 |
|
||||
|
||||
**treatment_records**
|
||||
|
||||
| 字段 | 类型 | 说明 |
|
||||
|------|------|------|
|
||||
| record_id | BIGINT AUTO_INCREMENT | 主键 |
|
||||
| session_id | VARCHAR(64) UNIQUE | 会话 ID(唯一标识一次护理) |
|
||||
| device_id / user_id | | 设备和用户 |
|
||||
| regions | VARCHAR(255) | 护理区域(逗号分隔或位掩码) |
|
||||
| total_duration_ms | INT UNSIGNED | 护理时长(毫秒) |
|
||||
| mode | TINYINT | 0=普通 1=智能 |
|
||||
| avg_pd | DECIMAL(8,4) | 平均光密度 |
|
||||
| pd_json | JSON | 详细光密度数据 |
|
||||
|
||||
---
|
||||
|
||||
## 鉴权系统
|
||||
|
||||
### 双密钥 JWT
|
||||
|
||||
系统使用两套独立的 JWT 密钥:
|
||||
|
||||
```
|
||||
用户 token: JWT_SECRET → type: 'user', payload: { user_id, openid }
|
||||
管理员 token: ADMIN_JWT_SECRET → type: 'admin', payload: { admin_id, username, role }
|
||||
```
|
||||
|
||||
两种 token 都是 7 天有效期。
|
||||
|
||||
### 用户登录流程
|
||||
|
||||
```
|
||||
小程序 wx.login() → code
|
||||
│
|
||||
▼
|
||||
POST /api/v1/auth/login { code }
|
||||
│
|
||||
├── server 调用微信 jscode2session 换取 openid
|
||||
├── 查询/自动创建 users 记录
|
||||
├── 签发 JWT(含 user_id, openid)
|
||||
└── 返回 { token, user_id, user_info, expires_in: 604800 }
|
||||
```
|
||||
|
||||
### 管理员登录流程
|
||||
|
||||
```
|
||||
POST /api/v1/admin/login { username, password }
|
||||
│
|
||||
├── 查询 admin_accounts 表
|
||||
├── bcrypt 验证密码
|
||||
│ └── 失败 → 尝试旧 SHA-256 验证
|
||||
│ └── 成功 → 自动迁移密码到 bcrypt
|
||||
├── 签发 JWT(含 admin_id, username, role)
|
||||
└── 返回 { token, admin_id, username, real_name, role }
|
||||
```
|
||||
|
||||
### Token 刷新
|
||||
|
||||
`POST /api/v1/auth/refresh`:
|
||||
|
||||
- token 未过期且剩余 > 7 天 → 原样返回
|
||||
- token 未过期且剩余 ≤ 7 天 → 签发新 token
|
||||
- token 已过期但在 3 天宽限期内 → 签发新 token
|
||||
- token 已过期超过 3 天 → 拒绝,需重新登录
|
||||
|
||||
---
|
||||
|
||||
## API 接口清单
|
||||
|
||||
### 认证与用户
|
||||
|
||||
| 方法 | 路径 | 鉴权 | 说明 |
|
||||
|------|------|------|------|
|
||||
| POST | `/api/v1/auth/login` | 无 | 微信登录,返回 JWT |
|
||||
| POST | `/api/v1/auth/refresh` | Bearer (可过期) | 刷新 token |
|
||||
| GET | `/api/v1/user/profile` | 用户 | 获取个人信息 |
|
||||
| PUT | `/api/v1/user/profile` | 用户 | 修改昵称/头像/性别 |
|
||||
| POST | `/api/v1/user/phone` | 用户 | 绑定手机号(微信授权) |
|
||||
|
||||
### 设备
|
||||
|
||||
| 方法 | 路径 | 鉴权 | 说明 |
|
||||
|------|------|------|------|
|
||||
| POST | `/api/v1/device/bind` | 用户 | 发起绑定(生成 bind_token,10分钟有效) |
|
||||
| POST | `/api/v1/device/bind/confirm` | 用户 | 确认绑定(BLE 握手完成后调用) |
|
||||
| POST | `/api/v1/device/unbind` | 用户 | 解绑设备 |
|
||||
| GET | `/api/v1/device/list` | 用户 | 已绑定设备列表 |
|
||||
| GET | `/api/v1/device/:device_id` | 用户 | 设备详情(校验归属) |
|
||||
| GET | `/api/v1/device/command/pending` | 用户 | 拉取待执行远程指令 |
|
||||
| POST | `/api/v1/device/command/result` | 用户 | 上报指令执行结果 |
|
||||
| POST | `/api/v1/device/event` | 用户 | 上报设备事件 |
|
||||
|
||||
### 订阅
|
||||
|
||||
| 方法 | 路径 | 鉴权 | 说明 |
|
||||
|------|------|------|------|
|
||||
| GET | `/api/v1/subscription` | 用户 | 查询当前订阅状态 |
|
||||
| POST | `/api/v1/subscription/purchase` | 用户 | 购买(未接入支付,仅生成订单号) |
|
||||
| POST | `/api/v1/subscription/verify` | 管理员 | 后台核销订阅(临时方案) |
|
||||
|
||||
### 护理记录
|
||||
|
||||
| 方法 | 路径 | 鉴权 | 说明 |
|
||||
|------|------|------|------|
|
||||
| GET | `/api/v1/treatment/history` | 用户 | 分页查询历史记录 |
|
||||
| POST | `/api/v1/treatment/sync` | 用户 | 同步护理记录(INSERT ON DUPLICATE) |
|
||||
| GET | `/api/v1/treatment/:record_id` | 用户 | 单条记录详情(按 session_id 查) |
|
||||
|
||||
### 固件
|
||||
|
||||
| 方法 | 路径 | 鉴权 | 说明 |
|
||||
|------|------|------|------|
|
||||
| GET | `/api/v1/firmware/latest` | 用户 | OTA 更新检查 |
|
||||
| GET | `/api/v1/admin/firmware` | 管理员 | 固件列表 |
|
||||
| POST | `/api/v1/admin/firmware` | 管理员 | 上传固件记录 |
|
||||
| POST | `/api/v1/admin/firmware/:id/status` | 管理员 | 启用/禁用固件 |
|
||||
|
||||
### 管理后台
|
||||
|
||||
| 方法 | 路径 | 鉴权 | 说明 |
|
||||
|------|------|------|------|
|
||||
| POST | `/api/v1/admin/login` | 无 | 管理员登录 |
|
||||
| GET | `/api/v1/admin/dashboard` | 管理员 | 仪表盘统计 |
|
||||
| GET/POST | `/api/v1/admin/devices` | 管理员 | 设备列表/预生成产品码 |
|
||||
| GET | `/api/v1/admin/devices/:id` | 管理员 | 设备详情 |
|
||||
| POST | `/api/v1/admin/devices/:id/unbind` | 管理员 | 强制解绑 |
|
||||
| POST | `/api/v1/admin/devices/:id/command` | 管理员 | 下发远程指令 |
|
||||
| GET | `/api/v1/admin/devices/:id/commands` | 管理员 | 指令历史 |
|
||||
| GET | `/api/v1/admin/users` | 管理员 | 用户列表 |
|
||||
| GET | `/api/v1/admin/users/:id` | 管理员 | 用户详情(含设备和最近护理) |
|
||||
| GET/POST | `/api/v1/admin/subscriptions` | 管理员 | 订阅列表/创建订阅 |
|
||||
| GET | `/api/v1/admin/records` | 管理员 | 所有护理记录 |
|
||||
| GET | `/api/v1/admin/logs` | 管理员 | 操作日志 |
|
||||
| GET/POST | `/api/v1/admin/settings` | 管理员 | 系统设置读写 |
|
||||
|
||||
---
|
||||
|
||||
## 外部服务集成
|
||||
|
||||
### 微信小程序 API
|
||||
|
||||
| 接口 | 用途 |
|
||||
|------|------|
|
||||
| `jscode2session` | 用 code 换取 openid(登录) |
|
||||
| `cgi-bin/token` | 获取 access_token(缓存在内存中,带 60s 安全边际) |
|
||||
| `getuserphonenumber` | 通过授权码获取用户手机号 |
|
||||
|
||||
开发环境绕过:如果 `NODE_ENV=development` 且 code 为空或以 `dev_` 开头,返回合成的 openid,不调用微信。
|
||||
|
||||
### 腾讯云 COS
|
||||
|
||||
用于固件文件存储。`getObjectUrl(key, expiresSeconds)` 生成预签名下载 URL,默认 3600 秒有效。
|
||||
|
||||
---
|
||||
|
||||
## 错误码约定
|
||||
|
||||
所有响应格式为 `{ code, message, data }`:
|
||||
|
||||
| code | 含义 |
|
||||
|------|------|
|
||||
| 0 | 成功 |
|
||||
| 404 | 路由不存在 |
|
||||
| 1001 | 鉴权失败(token 无效/过期) |
|
||||
| 1002 | 管理员权限不足 |
|
||||
| 1004 | 用户不存在 |
|
||||
| 1005 | 资源不存在(设备/记录) |
|
||||
| 1006 | 设备未绑定 |
|
||||
| 2001 | 参数校验失败 |
|
||||
| 3001 | 服务器内部错误 |
|
||||
|
||||
---
|
||||
|
||||
## 部署方式
|
||||
|
||||
```
|
||||
腾讯云 SCF(Web 函数)
|
||||
├── 入口:scf_bootstrap → node scripts/local-server.js
|
||||
├── 端口:9000
|
||||
├── 运行时:Node.js
|
||||
└── 触发方式:API Gateway HTTP 触发
|
||||
```
|
||||
|
||||
本地开发:`npm start` → `node scripts/local-server.js` → 监听 `config.port`(默认 3000)。
|
||||
@@ -0,0 +1,423 @@
|
||||
# 小程序架构
|
||||
|
||||
## 概述
|
||||
|
||||
微信原生小程序(非 uni-app),使用 WXML + WXSS + JS 开发。通过 BLE 5.0 与光子美容仪设备通信,通过 HTTP 与腾讯云 SCF 后端通信。
|
||||
|
||||
**AppID:** wxc4045074ef298510
|
||||
**项目名:** hox-beauty
|
||||
**基础库:** 3.15.2
|
||||
|
||||
---
|
||||
|
||||
## 目录结构
|
||||
|
||||
```
|
||||
miniprogram/
|
||||
├── app.js / app.json / app.wxss # 应用入口
|
||||
├── config/
|
||||
│ └── env.js # 环境配置(API 地址)
|
||||
├── pages/
|
||||
│ ├── index/ # 首页(Tab)—— 设备状态、开始护理
|
||||
│ ├── history/ # 记录(Tab)—— 护理历史列表
|
||||
│ ├── profile/ # 我的(Tab)—— 个人中心
|
||||
│ ├── login/ # 微信登录
|
||||
│ ├── scan/ # 扫码绑定设备
|
||||
│ ├── ble-connect/ # 蓝牙连接 + BLE 绑定
|
||||
│ ├── bind-success/ # 绑定成功
|
||||
│ ├── wear-check/ # 佩戴检测
|
||||
│ ├── treatment-setup/# 护理模式选择
|
||||
│ ├── auto-scan/ # 智能模式面部扫描
|
||||
│ ├── treating/ # 护理进行中
|
||||
│ ├── treatment-done/ # 护理完成
|
||||
│ ├── subscribe-prompt/ # 订阅引导
|
||||
│ ├── subscribe-plans/ # 订阅套餐选择
|
||||
│ └── subscribe-success/ # 订阅成功
|
||||
├── services/
|
||||
│ ├── ble.js # BLE 通信协议核心
|
||||
│ └── command-sync.js # 远程指令拉取与执行
|
||||
├── utils/
|
||||
│ ├── request.js # HTTP 请求封装 + token 刷新
|
||||
│ └── mock.js # 开发用 mock(已禁用)
|
||||
├── scripts/
|
||||
│ └── test-ble-frame.js # BLE 帧构建/解析单元测试
|
||||
├── project.config.json
|
||||
└── sitemap.json
|
||||
```
|
||||
|
||||
共 15 个页面,无自定义组件,全部使用微信原生组件。
|
||||
|
||||
---
|
||||
|
||||
## 应用生命周期
|
||||
|
||||
### app.js
|
||||
|
||||
**globalData:**
|
||||
|
||||
| 字段 | 用途 |
|
||||
|------|------|
|
||||
| `userInfo` | 用户信息对象(来自 `/api/v1/user/profile`) |
|
||||
| `userId` | 用户 ID 字符串 |
|
||||
| `connectedDevice` / `currentDevice` | 当前绑定的设备信息 |
|
||||
| `currentTreatment` | 最近一次护理结果(跨页传递) |
|
||||
| `statusBarHeight` | 系统状态栏高度(默认 44) |
|
||||
|
||||
**onLaunch 流程:**
|
||||
|
||||
1. `wx.getSystemInfoSync()` → 读取 `statusBarHeight`
|
||||
2. `checkLogin()` → 从 Storage 读取 token
|
||||
- 有 token → 调用 `loadProfile()` 加载用户信息
|
||||
- 无 token → 设置 `userInfo = null`(各页面自行跳转登录)
|
||||
|
||||
---
|
||||
|
||||
## 页面导航流程
|
||||
|
||||
### 主要业务路径
|
||||
|
||||
```
|
||||
┌─────────┐
|
||||
│ login │ ← 无 token 时跳转
|
||||
└────┬────┘
|
||||
│ wx.switchTab
|
||||
▼
|
||||
┌──────────────────────┐
|
||||
│ index(首页 Tab) │
|
||||
│ 设备状态 / 订阅状态 │
|
||||
└──────┬───────────────┘
|
||||
│
|
||||
┌──────────┼──────────┐
|
||||
│ 无设备 │ 有设备 │ 有设备但无订阅
|
||||
▼ ▼ ▼
|
||||
┌──────┐ ┌────────┐ ┌─────────────────┐
|
||||
│ scan │ │ wear- │ │subscribe-prompt │
|
||||
│ 扫码 │ │ check │ │ 订阅引导 │
|
||||
└──┬───┘ └───┬────┘ └───────┬─────────┘
|
||||
│ │ │
|
||||
▼ ▼ ▼
|
||||
┌───────────┐ ┌──────────────┐ ┌─────────────────┐
|
||||
│ble-connect│ │treatment- │ │subscribe-plans │
|
||||
│ 蓝牙配对 │ │setup 模式选择│ │ 套餐选择 │
|
||||
└─────┬─────┘ └──────┬───────┘ └─────────────────┘
|
||||
│ │
|
||||
▼ ┌────┴────┐
|
||||
┌───────────┐ │ │
|
||||
│bind- │ 普通模式 智能模式
|
||||
│success │ │ │
|
||||
└───────────┘ │ ┌────┴─────┐
|
||||
│ │auto-scan │
|
||||
│ │ 面部扫描 │
|
||||
│ └────┬─────┘
|
||||
│ │
|
||||
▼ ▼
|
||||
┌──────────────────┐
|
||||
│ treating │
|
||||
│ 护理进行中 │
|
||||
└────────┬─────────┘
|
||||
│
|
||||
▼
|
||||
┌──────────────────┐
|
||||
│ treatment-done │
|
||||
│ 护理完成 │
|
||||
└──────────────────┘
|
||||
```
|
||||
|
||||
### Tab 页
|
||||
|
||||
| Tab | 页面 | 说明 |
|
||||
|-----|------|------|
|
||||
| 首页 | index | 设备连接状态、开始护理入口 |
|
||||
| 记录 | history | 护理历史列表(分页、下拉刷新) |
|
||||
| 我的 | profile | 个人信息、订阅状态、设置 |
|
||||
|
||||
### 导航方式
|
||||
|
||||
- Tab 间切换:`wx.switchTab`
|
||||
- 业务流程前进:`wx.navigateTo`(压栈)或 `wx.redirectTo`(替换)
|
||||
- 强制跳转:`wx.reLaunch`(清空页面栈,用于登录跳转)
|
||||
|
||||
---
|
||||
|
||||
## 页面详情
|
||||
|
||||
### login — 微信登录
|
||||
|
||||
- 调用 `wx.getUserProfile()` 获取头像昵称(2022.10 后始终返回「微信用户」和灰色头像)
|
||||
- 调用 `app.doLogin()` → `wx.login()` 获取 code → `POST /api/v1/auth/login`
|
||||
- 登录后保存 `token` 和 `token_expiry` 到 Storage
|
||||
- 成功后 `wx.switchTab` 到首页
|
||||
|
||||
### index — 首页
|
||||
|
||||
- `GET /api/v1/device/list` 获取绑定设备列表
|
||||
- `GET /api/v1/subscription` 获取订阅状态
|
||||
- BLE:订阅 `ble.on('status')` 更新电量和设备状态
|
||||
- 连接设备后自动调用 `ble.queryStatus()`
|
||||
- 解绑操作:`ble.disconnect()` + `POST /api/v1/device/unbind`
|
||||
|
||||
### scan — 扫码绑定
|
||||
|
||||
- `wx.scanCode({ onlyFromCamera: true, scanType: ['qrCode'] })` 扫描设备二维码
|
||||
- 支持手动输入 16 位 hex 设备号
|
||||
- `POST /api/v1/device/bind` → 获取 `bind_token`
|
||||
- 跳转到 `ble-connect` 页面
|
||||
|
||||
### ble-connect — 蓝牙连接
|
||||
|
||||
- 状态机:`scanning → connecting → binding → done / error`
|
||||
- BLE 扫描过滤名称含 `HOX` 或 `LIGHTMASK` 的设备
|
||||
- 连接后发送 BIND 命令,等待 `bind_result` 通知
|
||||
- 成功后 `POST /api/v1/device/bind/confirm`
|
||||
|
||||
### wear-check — 佩戴检测
|
||||
|
||||
- 通过 `ble.queryStatus()` 检查设备 `bind_status === 1`
|
||||
- 确认佩戴后进入模式选择
|
||||
|
||||
### treatment-setup — 模式选择
|
||||
|
||||
- 普通模式(mode=0):全脸、10 分钟固定
|
||||
- 智能模式(mode=1):需要有效订阅,可选区域
|
||||
- 区域位掩码:左脸(0x01) 右脸(0x02) 额头(0x04) 下巴(0x08) 鼻部(0x10)
|
||||
- 普通模式直接发 `setParams` + `startTreatment` 开始护理
|
||||
- 智能模式先跳到 auto-scan 扫描
|
||||
|
||||
### treating — 护理中
|
||||
|
||||
- 订阅 6 个 BLE 事件:`status`, `treatment_complete`, `exception`, `disconnected`, `reconnected`, `reconnect_failed`
|
||||
- 本地 1 秒定时器作为倒计时后备
|
||||
- 接收设备 `status` 报告校正剩余时间
|
||||
- 每次 status 更新触发 `commandSync.sync(deviceId)` 拉取远程指令
|
||||
- 断连时暂停计时器,重连后恢复
|
||||
- 护理结束触发条件:设备完成通知 / 本地定时器归零 / 手动停止 / 重连失败
|
||||
|
||||
### treatment-done — 护理完成
|
||||
|
||||
- `POST /api/v1/treatment/sync` 同步记录到服务器
|
||||
- 展示护理时长、区域、模式等摘要
|
||||
|
||||
### history — 护理记录
|
||||
|
||||
- `GET /api/v1/treatment/history?page=N&page_size=20` 分页加载
|
||||
- 支持下拉刷新和触底加载
|
||||
- 累计统计:总次数、总时长、本月次数
|
||||
|
||||
### profile — 我的
|
||||
|
||||
- `GET /api/v1/user/profile` + `GET /api/v1/subscription`
|
||||
- 菜单入口:设备管理、护理记录、订阅管理、使用帮助、联系我们
|
||||
- 退出登录:清除 token → `wx.reLaunch` 到 login
|
||||
|
||||
---
|
||||
|
||||
## BLE 通信协议
|
||||
|
||||
### GATT 服务和特征值
|
||||
|
||||
| 服务 UUID | 名称 | 特征值 |
|
||||
|-----------|------|--------|
|
||||
| FFE0 | 设备信息服务 | FFE3 (设备信息 - 读) |
|
||||
| FFE1 | 数据通信服务 | FFE4 (命令 - 写), FFE5 (状态 - 通知), FFE6 (绑定信息) |
|
||||
| FFE2 | OTA 服务 | FFE7 (OTA控制), FFE8 (OTA数据), FFE9 (OTA状态) |
|
||||
|
||||
### 帧格式
|
||||
|
||||
```
|
||||
┌──────┬──────┬────────┬──────┬─────────────────┬──────────┐
|
||||
│ 0xAA │ 0x55 │ Length │ Type │ Payload │ Checksum │
|
||||
│ 1B │ 1B │ 1B │ 1B │ Length bytes │ 1B (XOR) │
|
||||
└──────┴──────┴────────┴──────┴─────────────────┴──────────┘
|
||||
```
|
||||
|
||||
- **Header**:固定 `0xAA 0x55`
|
||||
- **Length**:Payload 字节数
|
||||
- **Type**:命令/通知类型
|
||||
- **Checksum**:前面所有字节的 XOR 值
|
||||
|
||||
### 命令类型(小程序 → 设备)
|
||||
|
||||
| 类型 | 值 | Payload |
|
||||
|------|----|---------|
|
||||
| SET_PARAMS | 0x01 | region_mask, wavelength, brightness, duration_ms(4B大端), mode, seq |
|
||||
| START | 0x02 | region_mask, seq |
|
||||
| STOP | 0x03 | seq |
|
||||
| QUERY_STATUS | 0x04 | seq |
|
||||
| BIND | 0x05 | 0x01, userId(8B), bindToken(8B), timestamp(4B大端), seq |
|
||||
| UNBIND | 0x06 | 0x02, userId(8B), seq |
|
||||
|
||||
每条命令的 payload 末尾都有 `seq` 字节(0-255 循环),用于匹配 ACK 响应。
|
||||
|
||||
### 通知类型(设备 → 小程序)
|
||||
|
||||
| 类型 | 值 | Payload |
|
||||
|------|----|---------|
|
||||
| STATUS_REPORT | 0x21 | mode_state, region_mask, wavelength, brightness, remaining_ms(4B), error_code, command_seq, battery, temperature, bind_status, subscription — 共 14 字节 |
|
||||
| ACK | 0x22 | seq, error_code |
|
||||
| TREATMENT_COMPLETE | 0x31 | session_id(8B), regions, total_duration_ms(4B), avg_pd |
|
||||
| EXCEPTION | 0x32 | error_code, temperature |
|
||||
| BIND_SUCCESS | 0x33 | result (0x00=成功) |
|
||||
|
||||
### 命令重试机制
|
||||
|
||||
`writeCommandWithRetry(type, payload, maxRetries=3)`:
|
||||
|
||||
- 调用 `writeCommand` 发送命令
|
||||
- 失败时等待 500ms 后重试
|
||||
- 最多重试 3 次
|
||||
- 如果错误码为 `0x0C`(BLE 断连),立即失败不重试
|
||||
- ACK 超时时间:5 秒
|
||||
|
||||
### 自动重连机制
|
||||
|
||||
- 检测到 BLE 断连时自动触发
|
||||
- 等待 2 秒后第一次重连尝试
|
||||
- 最多 3 次,间隔 2 秒
|
||||
- 重连成功后重新发现服务、重新订阅特征值通知
|
||||
- 手动调用 `disconnect()` 时禁用自动重连
|
||||
|
||||
### 设备状态机
|
||||
|
||||
| 值 | 状态 | 说明 |
|
||||
|----|------|------|
|
||||
| 0x00 | IDLE | 空闲 |
|
||||
| 0x01 | SCANNING | 面部扫描中 |
|
||||
| 0x02 | ACTIVE | 护理中 |
|
||||
| 0x03 | PAUSED | 已暂停 |
|
||||
| 0x04 | COMPLETED | 已完成 |
|
||||
| 0x05 | ERROR | 异常 |
|
||||
| 0x06 | OTA | 固件升级中 |
|
||||
|
||||
### 区域位掩码
|
||||
|
||||
| 位 | 值 | 区域 |
|
||||
|----|----|------|
|
||||
| 0 | 0x01 | 左脸颊 |
|
||||
| 1 | 0x02 | 右脸颊 |
|
||||
| 2 | 0x04 | 额头 |
|
||||
| 3 | 0x08 | 下巴 |
|
||||
| 4 | 0x10 | 鼻部 |
|
||||
| 5 | 0x20 | 左眼周 |
|
||||
| 6 | 0x40 | 右眼周 |
|
||||
| 全部 | 0x7F | 全脸 |
|
||||
|
||||
### 设备错误码
|
||||
|
||||
| 码 | 名称 | 说明 |
|
||||
|----|------|------|
|
||||
| 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 | OTA 失败 |
|
||||
| 0x0C | ERR_BLE_DISCONNECTED | 蓝牙断连 |
|
||||
|
||||
---
|
||||
|
||||
## 远程指令同步(command-sync.js)
|
||||
|
||||
服务端可以通过管理后台向设备下发远程指令,小程序在护理过程中定期拉取并执行:
|
||||
|
||||
```
|
||||
commandSync.sync(deviceId)
|
||||
│
|
||||
├── GET /api/v1/device/command/pending?device_id=xxx
|
||||
│ └── 返回待执行命令列表(status=1 → 标记为 status=2)
|
||||
│
|
||||
├── 逐条执行 BLE 命令(按 opcode 分发到对应函数)
|
||||
│
|
||||
└── POST /api/v1/device/command/result
|
||||
└── 上报执行结果(成功/失败)
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## HTTP 请求层(request.js)
|
||||
|
||||
### Token 自动刷新
|
||||
|
||||
- 每次请求前检查 `token_expiry`:如果距离过期不到 24 小时,先调用 `POST /api/v1/auth/refresh` 刷新
|
||||
- 并发请求的刷新去重:使用 `_refreshing` 标志和 `_refreshQueue` 队列,保证同一时间只有一个刷新请求
|
||||
- 刷新成功后更新 Storage 中的 `token` 和 `token_expiry`
|
||||
|
||||
### 鉴权失败处理
|
||||
|
||||
- 响应 code 为 1001 或 1002 时:清除 token → `wx.reLaunch` 到 login 页
|
||||
|
||||
### 请求头
|
||||
|
||||
```
|
||||
Authorization: Bearer <token>
|
||||
Content-Type: application/json
|
||||
X-App-Version: 1.0.0
|
||||
X-Platform: wechat
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## 环境配置
|
||||
|
||||
| 环境 | API 地址 |
|
||||
|------|---------|
|
||||
| local | `http://localhost:3000` |
|
||||
| test | `https://1426323813-ilxkhlxf4p.ap-guangzhou.tencentscf.com` |
|
||||
| prod | 待配置(发布阻断项) |
|
||||
|
||||
当前设置:`ENV = 'test'`
|
||||
|
||||
---
|
||||
|
||||
## UI 模式
|
||||
|
||||
### 自定义导航栏
|
||||
|
||||
所有非 Tab 页使用 `"navigationStyle": "custom"`,通过 `statusBarHeight + 24px` 的 padding-top 避开系统状态栏:
|
||||
|
||||
```html
|
||||
<view class="page-header" style="padding-top: {{statusBarHeight + 24}}px;">
|
||||
```
|
||||
|
||||
### 品牌色
|
||||
|
||||
| 用途 | 色值 |
|
||||
|------|------|
|
||||
| 主色 | #E6508C(粉色) |
|
||||
| 辅色 | #DCB982(金色) |
|
||||
| 成功 | #52c41a(绿色) |
|
||||
| 信息 | #006699(蓝色) |
|
||||
| 错误 | #ff4d4f(红色) |
|
||||
|
||||
### 全局 CSS 类
|
||||
|
||||
定义在 `app.wxss`,包括:`.page-header`(带颜色变体)、`.btn-primary`(带颜色变体)、`.btn-secondary`、`.status-badge`、`.device-card`、`.tip-card`、工具类(`.mt-20`, `.mb-30`, `.text-muted` 等)。
|
||||
|
||||
---
|
||||
|
||||
## 数据流汇总
|
||||
|
||||
### 登录 → 绑定 → 护理 → 同步
|
||||
|
||||
```
|
||||
1. 登录
|
||||
wx.login() → code → POST /auth/login → token + user_info → Storage
|
||||
|
||||
2. 绑定设备
|
||||
扫码/手动输入 → device_id → POST /device/bind → bind_token
|
||||
→ BLE 扫描 + 连接 → BIND 命令(0x05) → 等待 BIND_SUCCESS(0x33)
|
||||
→ POST /device/bind/confirm → 自动创建 7 天试用订阅
|
||||
|
||||
3. 护理
|
||||
佩戴检测 → 模式选择 → SET_PARAMS(0x01) → START(0x02)
|
||||
→ 护理中(实时 STATUS 更新 + 指令同步)
|
||||
→ 完成(TREATMENT_COMPLETE 通知或本地定时器归零)
|
||||
|
||||
4. 记录同步
|
||||
POST /treatment/sync { session_id, device_id, regions, duration, mode, ... }
|
||||
```
|
||||
@@ -0,0 +1,386 @@
|
||||
# 管理后台架构
|
||||
|
||||
## 概述
|
||||
|
||||
管理后台是一个基于 uni-app + Vue 3 的单页应用,编译目标为 H5(浏览器),部署在腾讯云 COS 上作为静态站点。通过调用服务端 Admin API 实现设备管理、用户管理、订阅管理等后台功能。
|
||||
|
||||
**技术栈:** uni-app + Vue 3 + Pinia + Vite
|
||||
**AppID:** `__UNI__HOXADMIN`
|
||||
**路由模式:** Hash(`#/pages/dashboard/index`)
|
||||
|
||||
---
|
||||
|
||||
## 目录结构
|
||||
|
||||
```
|
||||
admin-console/
|
||||
├── index.html # SPA 入口 HTML
|
||||
├── package.json
|
||||
├── vite.config.js # Vite 配置(仅引入 uni 插件)
|
||||
├── scripts/
|
||||
│ └── deploy-cos.js # COS 静态站点部署脚本
|
||||
└── src/
|
||||
├── App.vue # 根组件(登录状态检查)
|
||||
├── main.js # 应用入口(createSSRApp + Pinia)
|
||||
├── manifest.json # uni-app 配置
|
||||
├── pages.json # 页面路由定义
|
||||
├── config/
|
||||
│ └── env.js # 环境配置(API 地址)
|
||||
├── components/
|
||||
│ └── AdminLayout.vue # 侧边栏 + 顶栏布局组件
|
||||
├── store/
|
||||
│ └── user.js # Pinia 用户状态(token、管理员信息)
|
||||
├── utils/
|
||||
│ ├── request.js # HTTP 请求封装(Bearer token 注入)
|
||||
│ ├── export.js # CSV 导出工具
|
||||
│ └── format.js # 日期格式化
|
||||
├── styles/
|
||||
│ └── common.css # 表格、徽标、按钮等公共样式
|
||||
└── pages/
|
||||
├── login/index.vue # 登录页
|
||||
├── dashboard/index.vue # 仪表盘
|
||||
├── device/index.vue # 设备列表
|
||||
├── device-detail/index.vue # 设备详情
|
||||
├── user/index.vue # 用户列表
|
||||
├── user-detail/index.vue # 用户详情
|
||||
├── subscription/index.vue# 订阅管理
|
||||
├── record/index.vue # 护理记录
|
||||
├── log/index.vue # 操作日志
|
||||
└── settings/index.vue # 系统设置
|
||||
```
|
||||
|
||||
共 10 个页面,1 个全局组件,1 个 Store。
|
||||
|
||||
---
|
||||
|
||||
## 应用启动流程
|
||||
|
||||
```
|
||||
index.html → main.js
|
||||
│
|
||||
├── createSSRApp(App)
|
||||
├── app.use(createPinia())
|
||||
└── App.vue onLaunch()
|
||||
│
|
||||
├── useUserStore().isLoggedIn()
|
||||
│ ├── 检查 Storage 中 admin_token 是否存在
|
||||
│ └── 检查 admin_token_expiry 是否未过期(7天有效)
|
||||
│
|
||||
├── 已登录 → 停留在当前页
|
||||
└── 未登录 → uni.reLaunch('/pages/login/index')
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## 页面一览
|
||||
|
||||
### 登录页
|
||||
|
||||
| 项目 | 说明 |
|
||||
|------|------|
|
||||
| 路径 | `/pages/login/index` |
|
||||
| 导航栏 | 自定义(无系统导航栏) |
|
||||
| API | `POST /api/v1/admin/login` |
|
||||
| 功能 | 用户名+密码登录,成功后跳转到仪表盘 |
|
||||
|
||||
登录成功后:
|
||||
1. `userStore.login()` 保存 token 和管理员信息
|
||||
2. `uni.reLaunch('/pages/dashboard/index')`
|
||||
|
||||
### 仪表盘
|
||||
|
||||
| 项目 | 说明 |
|
||||
|------|------|
|
||||
| 路径 | `/pages/dashboard/index` |
|
||||
| API | `GET /admin/dashboard`, `GET /admin/records?page=1&page_size=5` |
|
||||
| 功能 | 4 个统计卡片 + 最近护理表格 + 快捷导航 |
|
||||
|
||||
4 个统计指标:
|
||||
|
||||
| 指标 | 字段 | 图标 |
|
||||
|------|------|------|
|
||||
| 绑定设备数 | device_count | 📱 |
|
||||
| 注册用户数 | user_count | 👥 |
|
||||
| 护理次数 | treatment_count | 💆 |
|
||||
| 活跃订阅 | subscription_count | 💳 |
|
||||
|
||||
### 设备管理
|
||||
|
||||
| 项目 | 说明 |
|
||||
|------|------|
|
||||
| 路径 | `/pages/device/index` |
|
||||
| API | `GET /admin/devices`, `POST /admin/devices` |
|
||||
| 功能 | 设备列表(分页、搜索)、预生成产品码、CSV 导出 |
|
||||
|
||||
状态显示:未激活、在线、离线、禁用
|
||||
|
||||
### 设备详情
|
||||
|
||||
| 项目 | 说明 |
|
||||
|------|------|
|
||||
| 路径 | `/pages/device-detail/index` |
|
||||
| API | `GET /admin/devices/:id`, `POST .../unbind`, `POST .../command` |
|
||||
| 功能 | 设备信息网格、绑定历史、护理记录、解绑、远程指令 |
|
||||
|
||||
### 用户管理
|
||||
|
||||
| 项目 | 说明 |
|
||||
|------|------|
|
||||
| 路径 | `/pages/user/index` |
|
||||
| API | `GET /admin/users` |
|
||||
| 功能 | 用户列表(分页、搜索)、订阅状态徽标、CSV 导出 |
|
||||
|
||||
### 用户详情
|
||||
|
||||
| 项目 | 说明 |
|
||||
|------|------|
|
||||
| 路径 | `/pages/user-detail/index` |
|
||||
| API | `GET /admin/users/:user_id` |
|
||||
| 功能 | 用户信息、统计行(护理次数/时长/消费)、最近护理记录 |
|
||||
|
||||
### 订阅管理
|
||||
|
||||
| 项目 | 说明 |
|
||||
|------|------|
|
||||
| 路径 | `/pages/subscription/index` |
|
||||
| API | `GET /admin/subscriptions`, `POST /admin/subscriptions` |
|
||||
| 功能 | 统计行、Tab 过滤(全部/月度/年度/试用/过期)、创建订阅弹窗、CSV 导出 |
|
||||
|
||||
创建订阅表单字段:user_id, plan, amount, days
|
||||
|
||||
### 护理记录
|
||||
|
||||
| 项目 | 说明 |
|
||||
|------|------|
|
||||
| 路径 | `/pages/record/index` |
|
||||
| API | `GET /admin/records` |
|
||||
| 功能 | 记录列表(分页、日期范围筛选、关键字搜索)、区域位掩码解码显示、CSV 导出 |
|
||||
|
||||
区域解码映射:1=左脸 2=右脸 4=额头 8=下巴 16=鼻 32=左眼 64=右眼
|
||||
|
||||
### 操作日志
|
||||
|
||||
| 项目 | 说明 |
|
||||
|------|------|
|
||||
| 路径 | `/pages/log/index` |
|
||||
| API | `GET /admin/logs` |
|
||||
| 功能 | 时间线样式日志列表、类型过滤、角色徽标(管理员/系统/用户)、CSV 导出 |
|
||||
|
||||
### 系统设置
|
||||
|
||||
| 项目 | 说明 |
|
||||
|------|------|
|
||||
| 路径 | `/pages/settings/index` |
|
||||
| API | `GET /admin/settings`, `POST /admin/settings` |
|
||||
| 功能 | 三组配置 + 保存/重置按钮 |
|
||||
|
||||
配置项分组:
|
||||
|
||||
| 分组 | 字段 |
|
||||
|------|------|
|
||||
| 基本配置 | system_name, admin_email, timezone |
|
||||
| 订阅定价 | monthly_price, yearly_price, trial_days |
|
||||
| 功能开关 | enable_register, enable_binding, enable_free_mode, maintenance_mode |
|
||||
|
||||
---
|
||||
|
||||
## 全局布局组件 — AdminLayout
|
||||
|
||||
每个页面(除登录页)都包裹在 `<AdminLayout>` 组件中:
|
||||
|
||||
```
|
||||
┌─────────────────────────────────────────────┐
|
||||
│ Sidebar (220px) │ Header │
|
||||
│ │ 页面标题 管理员名 退出 │
|
||||
│ 🌸 光子美容仪 ├──────────────────────────┤
|
||||
│ 管理后台 │ │
|
||||
│ │ Content (<slot>) │
|
||||
│ ▸ 仪表盘 │ │
|
||||
│ ▸ 设备管理 │ │
|
||||
│ ▸ 用户管理 │ │
|
||||
│ ▸ 订阅管理 │ │
|
||||
│ ▸ 护理记录 │ │
|
||||
│ ▸ 操作日志 │ │
|
||||
│ ▸ 系统设置 │ │
|
||||
│ │ │
|
||||
└─────────────────────────────────────────────┘
|
||||
```
|
||||
|
||||
- **Props**:`currentPage` — 当前页面路径,用于高亮侧边栏菜单项
|
||||
- **侧边栏导航**:使用 `uni.redirectTo`(替换页面,不累积页面栈)
|
||||
- **退出登录**:`store.clearToken()` → `uni.reLaunch('/pages/login/index')`
|
||||
- **管理员名称**:从 `userStore.adminInfo.real_name` 读取
|
||||
|
||||
---
|
||||
|
||||
## 状态管理 — Pinia Store
|
||||
|
||||
### user.js
|
||||
|
||||
```js
|
||||
// State
|
||||
token: ref('') // 从 Storage 初始化
|
||||
adminInfo: ref(null) // { admin_id, username, real_name, role }
|
||||
|
||||
// Actions
|
||||
setToken(val) // 保存 token + 计算过期时间(7天后)
|
||||
clearToken() // 清除 token + adminInfo + Storage
|
||||
login({ username, password }) // POST /admin/login → setToken + adminInfo
|
||||
isLoggedIn() // 检查 token 非空 && 未过期
|
||||
```
|
||||
|
||||
Token 存储位置:`uni.setStorageSync('admin_token')`
|
||||
过期时间存储:`uni.setStorageSync('admin_token_expiry')`
|
||||
有效期:7 天(客户端计算)
|
||||
|
||||
---
|
||||
|
||||
## HTTP 请求层(request.js)
|
||||
|
||||
### 请求流程
|
||||
|
||||
```
|
||||
页面调用 get/post(url, data)
|
||||
│
|
||||
▼
|
||||
request(options)
|
||||
│
|
||||
├── 从 Storage 读取 admin_token
|
||||
├── 注入 Authorization: Bearer <token>
|
||||
├── 拼接 BASE_URL + url
|
||||
│
|
||||
▼
|
||||
uni.request()
|
||||
│
|
||||
├── code === 0 → resolve(res.data.data)
|
||||
├── code === 1001/1002 → 清除 token → reLaunch 到 login → reject
|
||||
└── 其他 code → reject(res.data)
|
||||
```
|
||||
|
||||
### 注意事项
|
||||
|
||||
- Token 直接从 `uni.getStorageSync('admin_token')` 读取,不通过 Pinia store
|
||||
- 没有 token 刷新机制(不同于小程序端),token 过期后走 1001/1002 重定向登录
|
||||
- PUT 方法已定义但目前没有页面使用
|
||||
|
||||
---
|
||||
|
||||
## CSV 导出(export.js)
|
||||
|
||||
所有列表页都有"导出"按钮,调用 `exportCSV(filename, headers, rows)` :
|
||||
|
||||
1. 构建 CSV 字符串(带 BOM `` 确保 Excel 正确识别编码)
|
||||
2. 创建 Blob → 临时 `<a>` 元素 → 触发下载
|
||||
3. 仅 H5 端可用(使用 `document.createElement`)
|
||||
|
||||
---
|
||||
|
||||
## 样式体系
|
||||
|
||||
### 品牌色
|
||||
|
||||
| 色值 | 用途 |
|
||||
|------|------|
|
||||
| #E6508C | 主色(按钮、活跃状态、链接) |
|
||||
| #001529 | 侧边栏背景(深蓝) |
|
||||
| #f0f2f5 | 页面背景 |
|
||||
| #52c41a | 成功状态徽标 |
|
||||
| #faad14 | 警告状态徽标 |
|
||||
| #ff4d4f | 错误状态徽标 |
|
||||
|
||||
### 公共样式(common.css)
|
||||
|
||||
被 device / user / subscription / record / log / device-detail / user-detail 7 个页面引用,包含:
|
||||
|
||||
- **Flex 表格**:`.data-table`, `.t-header`, `.t-row`, `.t-th`, `.t-td`, `.flex1/.flex2/.flex3`
|
||||
- **徽标**:`.badge`, `.badge-success/.warning/.error/.default/.blue`
|
||||
- **按钮**:`.btn-primary`(粉色), `.btn-default`(白色边框), `.btn-sm`
|
||||
- **分页**:`.pagination`, `.btn-page`, `.page-info`
|
||||
- **工具栏**:`.toolbar`, `.header-actions`, `.search-input`
|
||||
- **卡片**:`.page-card`
|
||||
- **操作链接**:`.action-link`(粉色文字)
|
||||
|
||||
Dashboard 和 Login 各自独立定义样式,不引用 common.css。
|
||||
|
||||
---
|
||||
|
||||
## 路由与导航
|
||||
|
||||
### pages.json 页面注册顺序
|
||||
|
||||
1. login(入口页)
|
||||
2. dashboard
|
||||
3. device → device-detail
|
||||
4. user → user-detail
|
||||
5. subscription
|
||||
6. record
|
||||
7. log
|
||||
8. settings
|
||||
|
||||
没有 tabBar。登录页使用自定义导航栏,其他页面使用系统导航栏(白底黑字)。
|
||||
|
||||
### 导航方式
|
||||
|
||||
| 方法 | 使用场景 |
|
||||
|------|---------|
|
||||
| `uni.reLaunch` | 登录跳转、退出登录(清空页面栈) |
|
||||
| `uni.redirectTo` | 侧边栏菜单切换(替换当前页) |
|
||||
| `uni.navigateTo` | 进入详情页(压栈,可返回) |
|
||||
| `uni.navigateBack` | 详情页返回列表 |
|
||||
|
||||
---
|
||||
|
||||
## 鉴权流程
|
||||
|
||||
```
|
||||
1. 启动 App.vue onLaunch()
|
||||
└── isLoggedIn() → 检查 admin_token 存在 + admin_token_expiry 未过期
|
||||
├── 否 → uni.reLaunch('/pages/login/index')
|
||||
└── 是 → 正常显示
|
||||
|
||||
2. 登录 POST /admin/login
|
||||
└── 成功 → setToken(token)
|
||||
├── Storage: admin_token = token
|
||||
├── Storage: admin_token_expiry = now + 7天
|
||||
└── 内存: adminInfo = { admin_id, username, real_name, role }
|
||||
|
||||
3. 每次 API 请求
|
||||
└── request.js 从 Storage 读取 admin_token → Bearer header
|
||||
├── 服务端返回 1001/1002 → 清 token → 跳登录
|
||||
└── 正常响应 → 继续
|
||||
|
||||
4. 退出
|
||||
└── AdminLayout 退出按钮 → clearToken() → reLaunch 登录
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## 部署
|
||||
|
||||
### 构建
|
||||
|
||||
```bash
|
||||
npm run build:h5 # uni build -p h5,输出到 dist/build/h5/
|
||||
```
|
||||
|
||||
### 部署到 COS
|
||||
|
||||
```bash
|
||||
npm run deploy:cos # 构建 + 上传到 COS
|
||||
```
|
||||
|
||||
`scripts/deploy-cos.js`:
|
||||
- 从 `server/.env` 读取 COS 凭证
|
||||
- 上传 `dist/build/h5/` 下所有文件到 COS
|
||||
- 文件路径前缀:`admin/`(可通过 `ADMIN_COS_PREFIX` 环境变量配置)
|
||||
- 自动设置正确的 `Content-Type`
|
||||
|
||||
### 环境配置
|
||||
|
||||
| 环境 | API 地址 |
|
||||
|------|---------|
|
||||
| local | `http://localhost:3000` |
|
||||
| test | `https://1426323813-ilxkhlxf4p.ap-guangzhou.tencentscf.com` |
|
||||
| prod | 待配置 |
|
||||
|
||||
当前设置:`ENV = 'test'`
|
||||
@@ -238,3 +238,30 @@ page {
|
||||
.mt-30 { margin-top: 30rpx; }
|
||||
.mb-20 { margin-bottom: 20rpx; }
|
||||
.mb-30 { margin-bottom: 30rpx; }
|
||||
|
||||
.nav-back {
|
||||
font-size: 28rpx;
|
||||
color: rgba(255, 255, 255, 0.85);
|
||||
padding: 8rpx 0;
|
||||
margin-bottom: 8rpx;
|
||||
}
|
||||
|
||||
.debug-actions {
|
||||
margin-top: 40rpx;
|
||||
padding: 20rpx;
|
||||
background: #fff8e1;
|
||||
border-radius: 12rpx;
|
||||
border: 2rpx dashed #f5a623;
|
||||
}
|
||||
|
||||
.debug-label {
|
||||
font-size: 24rpx;
|
||||
color: #f5a623;
|
||||
margin-bottom: 16rpx;
|
||||
font-weight: 600;
|
||||
}
|
||||
|
||||
.debug-actions .btn-sm {
|
||||
margin-top: 12rpx;
|
||||
font-size: 26rpx;
|
||||
}
|
||||
|
||||
@@ -7,7 +7,10 @@ var API_BASES = {
|
||||
prod: 'https://replace-with-scf-prod-url' // TODO: replace with actual production SCF URL
|
||||
}
|
||||
|
||||
var __DEV__ = ENV !== 'prod'
|
||||
|
||||
module.exports = {
|
||||
ENV: ENV,
|
||||
API_BASE: API_BASES[ENV]
|
||||
API_BASE: API_BASES[ENV],
|
||||
__DEV__: __DEV__
|
||||
}
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
var ble = require('../../services/ble')
|
||||
var config = require('../../config/env')
|
||||
|
||||
Page({
|
||||
data: {
|
||||
@@ -8,14 +9,24 @@ Page({
|
||||
regions: 0x7F,
|
||||
regionData: [],
|
||||
timeout: false,
|
||||
error: ''
|
||||
error: '',
|
||||
devMode: false
|
||||
},
|
||||
|
||||
onBack: function () {
|
||||
wx.navigateBack({
|
||||
fail: function () {
|
||||
wx.reLaunch({ url: '/pages/index/index' })
|
||||
}
|
||||
})
|
||||
},
|
||||
|
||||
onLoad: function (options) {
|
||||
var app = getApp()
|
||||
this.setData({
|
||||
statusBarHeight: app.globalData.statusBarHeight,
|
||||
regions: parseInt(options.regions) || 0x7F
|
||||
regions: parseInt(options.regions) || 0x7F,
|
||||
devMode: config.__DEV__ || false
|
||||
})
|
||||
this.startScan()
|
||||
},
|
||||
@@ -80,5 +91,17 @@ Page({
|
||||
}).catch(function (err) {
|
||||
wx.showToast({ title: err.error_msg || '启动失败', icon: 'none' })
|
||||
})
|
||||
},
|
||||
|
||||
onMockScanDone: function () {
|
||||
var self = this
|
||||
if (self._progressTimer) clearInterval(self._progressTimer)
|
||||
var mask = parseInt(self.options.regions) || 0x7F
|
||||
wx.redirectTo({
|
||||
url: '/pages/treating/treating?regions=' + mask +
|
||||
'&wavelength=2' +
|
||||
'&duration=600000' +
|
||||
'&mode=1'
|
||||
})
|
||||
}
|
||||
})
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
<view class="page">
|
||||
<view class="page-header page-header-pink" style="padding-top: {{statusBarHeight + 24}}px;">
|
||||
<view class="nav-back" bindtap="onBack">‹ 返回</view>
|
||||
<view class="page-header-title">自动扫描</view>
|
||||
<view class="page-header-subtitle">正在识别面部...</view>
|
||||
</view>
|
||||
@@ -15,6 +16,11 @@
|
||||
|
||||
<view class="scan-hint">请将面罩对准面部</view>
|
||||
|
||||
<view class="debug-actions" wx:if="{{devMode}}">
|
||||
<view class="debug-label">🔧 调试工具</view>
|
||||
<button class="btn-secondary btn-sm" bindtap="onMockScanDone">模拟扫描完成</button>
|
||||
</view>
|
||||
|
||||
<view class="scan-progress" wx:if="{{scanning}}">
|
||||
正在扫描... <text class="highlight">{{regionData.length || 3}}</text> 个区域
|
||||
</view>
|
||||
|
||||
@@ -5,6 +5,14 @@ Page({
|
||||
trialDays: 7
|
||||
},
|
||||
|
||||
onBack: function () {
|
||||
wx.navigateBack({
|
||||
fail: function () {
|
||||
wx.reLaunch({ url: '/pages/index/index' })
|
||||
}
|
||||
})
|
||||
},
|
||||
|
||||
onLoad: function (options) {
|
||||
var app = getApp()
|
||||
this.setData({ statusBarHeight: app.globalData.statusBarHeight })
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
<view class="page">
|
||||
<view class="page-header page-header-green" style="padding-top: {{statusBarHeight + 24}}px;">
|
||||
<view class="nav-back" bindtap="onBack">‹ 返回</view>
|
||||
<view class="page-header-title">绑定成功</view>
|
||||
<view class="page-header-subtitle">设备已成功绑定</view>
|
||||
</view>
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
var ble = require('../../services/ble')
|
||||
var http = require('../../utils/request')
|
||||
var config = require('../../config/env')
|
||||
var app = getApp()
|
||||
var SCAN_TIMEOUT = 15000
|
||||
|
||||
@@ -9,14 +10,24 @@ Page({
|
||||
deviceId: '',
|
||||
bindToken: '',
|
||||
state: 'scanning',
|
||||
error: ''
|
||||
error: '',
|
||||
devMode: false
|
||||
},
|
||||
|
||||
onBack: function () {
|
||||
wx.navigateBack({
|
||||
fail: function () {
|
||||
wx.reLaunch({ url: '/pages/index/index' })
|
||||
}
|
||||
})
|
||||
},
|
||||
|
||||
onLoad: function (options) {
|
||||
this.setData({ statusBarHeight: app.globalData.statusBarHeight })
|
||||
this.setData({
|
||||
deviceId: options.device_id || '',
|
||||
bindToken: options.bind_token || ''
|
||||
bindToken: options.bind_token || '',
|
||||
devMode: config.__DEV__ || false
|
||||
})
|
||||
this.startBleConnect()
|
||||
},
|
||||
@@ -83,5 +94,40 @@ Page({
|
||||
onRetry: function () {
|
||||
ble.disconnect()
|
||||
this.startBleConnect()
|
||||
},
|
||||
|
||||
onMockConnect: function () {
|
||||
var self = this
|
||||
clearTimeout(this._scanTimer)
|
||||
ble.stopScan()
|
||||
self.setData({ state: 'binding' })
|
||||
// Skip BLE bind, confirm directly with server
|
||||
http.post('/api/v1/device/bind/confirm', {
|
||||
device_id: self.data.deviceId,
|
||||
bind_token: self.data.bindToken
|
||||
}).then(function () {
|
||||
self.setData({ state: 'done' })
|
||||
wx.redirectTo({
|
||||
url: '/pages/bind-success/bind-success?device_id=' + self.data.deviceId
|
||||
})
|
||||
}).catch(function (err) {
|
||||
self.setData({ state: 'error', error: err.message || '模拟确认失败' })
|
||||
})
|
||||
},
|
||||
|
||||
onMockBindSuccess: function () {
|
||||
var self = this
|
||||
clearTimeout(this._scanTimer)
|
||||
ble.stopScan()
|
||||
http.post('/api/v1/device/bind/confirm', {
|
||||
device_id: self.data.deviceId,
|
||||
bind_token: self.data.bindToken
|
||||
}).then(function () {
|
||||
wx.redirectTo({
|
||||
url: '/pages/bind-success/bind-success?device_id=' + self.data.deviceId
|
||||
})
|
||||
}).catch(function (err) {
|
||||
self.setData({ state: 'error', error: err.message || '模拟确认失败' })
|
||||
})
|
||||
}
|
||||
})
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
<view class="page">
|
||||
<view class="page-header page-header-pink" style="padding-top: {{statusBarHeight + 24}}px;">
|
||||
<view class="nav-back" bindtap="onBack">‹ 返回</view>
|
||||
<view class="page-header-title">蓝牙连接</view>
|
||||
<view class="page-header-subtitle">{{state === 'scanning' ? '正在搜索设备...' : state === 'connecting' ? '正在连接设备...' : state === 'binding' ? '正在绑定...' : '连接失败'}}</view>
|
||||
</view>
|
||||
@@ -26,5 +27,11 @@
|
||||
<view class="found-name">{{error || '连接失败'}}</view>
|
||||
<button class="retry-btn" bindtap="onRetry">重试</button>
|
||||
</view>
|
||||
|
||||
<view class="debug-actions" wx:if="{{devMode}}">
|
||||
<view class="debug-label">🔧 调试工具</view>
|
||||
<button class="btn-secondary btn-sm" bindtap="onMockConnect">模拟连接成功</button>
|
||||
<button class="btn-secondary btn-sm" bindtap="onMockBindSuccess">模拟绑定成功</button>
|
||||
</view>
|
||||
</view>
|
||||
</view>
|
||||
|
||||
@@ -8,6 +8,14 @@ Page({
|
||||
error: ''
|
||||
},
|
||||
|
||||
onBack: function () {
|
||||
wx.navigateBack({
|
||||
fail: function () {
|
||||
wx.reLaunch({ url: '/pages/index/index' })
|
||||
}
|
||||
})
|
||||
},
|
||||
|
||||
onLoad: function () {
|
||||
var app = getApp()
|
||||
this.setData({ statusBarHeight: app.globalData.statusBarHeight })
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
<view class="page">
|
||||
<view class="page-header page-header-pink" style="padding-top: {{statusBarHeight + 24}}px;">
|
||||
<view class="nav-back" bindtap="onBack">‹ 返回</view>
|
||||
<view class="page-header-title">扫码绑定</view>
|
||||
<view class="page-header-subtitle">扫描设备底部二维码</view>
|
||||
</view>
|
||||
|
||||
@@ -11,6 +11,14 @@ Page({
|
||||
purchasing: false
|
||||
},
|
||||
|
||||
onBack: function () {
|
||||
wx.navigateBack({
|
||||
fail: function () {
|
||||
wx.reLaunch({ url: '/pages/index/index' })
|
||||
}
|
||||
})
|
||||
},
|
||||
|
||||
onLoad: function () {
|
||||
var app = getApp()
|
||||
this.setData({ statusBarHeight: app.globalData.statusBarHeight })
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
<view class="page">
|
||||
<view class="page-header page-header-gold" style="padding-top: {{statusBarHeight + 24}}px;">
|
||||
<view class="nav-back" bindtap="onBack">‹ 返回</view>
|
||||
<view class="page-header-title">订阅服务</view>
|
||||
<view class="page-header-subtitle">解锁智能模式</view>
|
||||
</view>
|
||||
|
||||
@@ -13,6 +13,10 @@ Page({
|
||||
},
|
||||
|
||||
onBack: function () {
|
||||
wx.switchTab({ url: '/pages/index/index' })
|
||||
wx.navigateBack({
|
||||
fail: function () {
|
||||
wx.reLaunch({ url: '/pages/index/index' })
|
||||
}
|
||||
})
|
||||
}
|
||||
})
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
<view class="page">
|
||||
<view class="page-header page-header-pink" style="padding-top: {{statusBarHeight + 24}}px;">
|
||||
<view class="nav-back" bindtap="onBack">‹ 返回</view>
|
||||
<view class="page-header-title">护理设置</view>
|
||||
<view class="page-header-subtitle">试用期已结束</view>
|
||||
</view>
|
||||
|
||||
@@ -5,6 +5,14 @@ Page({
|
||||
expiryDate: ''
|
||||
},
|
||||
|
||||
onBack: function () {
|
||||
wx.navigateBack({
|
||||
fail: function () {
|
||||
wx.reLaunch({ url: '/pages/index/index' })
|
||||
}
|
||||
})
|
||||
},
|
||||
|
||||
onLoad: function (options) {
|
||||
var app = getApp()
|
||||
this.setData({ statusBarHeight: app.globalData.statusBarHeight })
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
<view class="page">
|
||||
<view class="page-header page-header-green" style="padding-top: {{statusBarHeight + 24}}px;">
|
||||
<view class="nav-back" bindtap="onBack">‹ 返回</view>
|
||||
<view class="page-header-title">订阅成功</view>
|
||||
<view class="page-header-subtitle">欢迎使用智能模式</view>
|
||||
</view>
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
var ble = require('../../services/ble')
|
||||
var commandSync = require('../../services/command-sync')
|
||||
var config = require('../../config/env')
|
||||
|
||||
Page({
|
||||
data: {
|
||||
@@ -16,11 +17,12 @@ Page({
|
||||
paused: false,
|
||||
completed: false,
|
||||
startedAt: 0,
|
||||
devMode: false,
|
||||
},
|
||||
|
||||
onLoad: function (options) {
|
||||
var app = getApp()
|
||||
this.setData({ statusBarHeight: app.globalData.statusBarHeight })
|
||||
this.setData({ statusBarHeight: app.globalData.statusBarHeight, devMode: config.__DEV__ || false })
|
||||
this.setData({
|
||||
regions: parseInt(options.regions) || 0x7F,
|
||||
wavelength: parseInt(options.wavelength) || 2,
|
||||
@@ -187,5 +189,9 @@ Page({
|
||||
}
|
||||
}
|
||||
})
|
||||
},
|
||||
|
||||
onMockComplete: function () {
|
||||
this.finishAsComplete()
|
||||
}
|
||||
})
|
||||
|
||||
@@ -26,6 +26,11 @@
|
||||
</view>
|
||||
</view>
|
||||
|
||||
<view class="debug-actions" wx:if="{{devMode}}">
|
||||
<view class="debug-label">🔧 调试工具</view>
|
||||
<button class="btn-secondary btn-sm" bindtap="onMockComplete">模拟护理完成</button>
|
||||
</view>
|
||||
|
||||
<button class="btn-primary btn-primary-red" bindtap="onStop">停止护理</button>
|
||||
</view>
|
||||
</view>
|
||||
|
||||
@@ -16,6 +16,10 @@ Page({
|
||||
synced: false
|
||||
},
|
||||
|
||||
onBack: function () {
|
||||
wx.reLaunch({ url: '/pages/index/index' })
|
||||
},
|
||||
|
||||
onLoad: function (options) {
|
||||
this.setData({ statusBarHeight: app.globalData.statusBarHeight })
|
||||
var durationMs = parseInt(options.duration) || 0
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
<view class="page">
|
||||
<view class="page-header page-header-green" style="padding-top: {{statusBarHeight + 24}}px;">
|
||||
<view class="nav-back" bindtap="onBack">‹ 返回</view>
|
||||
<view class="page-header-title">护理完成</view>
|
||||
<view class="page-header-subtitle">本次护理已结束</view>
|
||||
</view>
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
var ble = require('../../services/ble')
|
||||
var config = require('../../config/env')
|
||||
|
||||
Page({
|
||||
data: {
|
||||
@@ -15,12 +16,21 @@ Page({
|
||||
subDays: 6,
|
||||
fixedDurationMs: 600000,
|
||||
submitting: false,
|
||||
error: ''
|
||||
error: '',
|
||||
devMode: false
|
||||
},
|
||||
|
||||
onBack: function () {
|
||||
wx.navigateBack({
|
||||
fail: function () {
|
||||
wx.reLaunch({ url: '/pages/index/index' })
|
||||
}
|
||||
})
|
||||
},
|
||||
|
||||
onLoad: function () {
|
||||
var app = getApp()
|
||||
this.setData({ statusBarHeight: app.globalData.statusBarHeight })
|
||||
this.setData({ statusBarHeight: app.globalData.statusBarHeight, devMode: config.__DEV__ || false })
|
||||
this.checkSubscription()
|
||||
},
|
||||
|
||||
@@ -97,5 +107,19 @@ Page({
|
||||
}).catch(function (err) {
|
||||
self.setData({ submitting: false, error: err.error_msg || '启动失败' })
|
||||
})
|
||||
},
|
||||
|
||||
onMockStart: function () {
|
||||
var mask = 0
|
||||
this.data.regions.forEach(function (r) {
|
||||
if (r.checked) mask |= r.mask
|
||||
})
|
||||
if (mask === 0) mask = 0x7F
|
||||
wx.redirectTo({
|
||||
url: '/pages/treating/treating?regions=' + mask +
|
||||
'&wavelength=2' +
|
||||
'&duration=' + this.data.fixedDurationMs +
|
||||
'&mode=' + this.data.selectedMode
|
||||
})
|
||||
}
|
||||
})
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
<view class="page">
|
||||
<view class="page-header page-header-pink" style="padding-top: {{statusBarHeight + 24}}px;">
|
||||
<view class="nav-back" bindtap="onBack">‹ 返回</view>
|
||||
<view class="page-header-title">护理设置</view>
|
||||
<view class="page-header-subtitle">选择模式和区域</view>
|
||||
</view>
|
||||
@@ -37,5 +38,10 @@
|
||||
</button>
|
||||
|
||||
<view class="error-text" wx:if="{{error}}">{{error}}</view>
|
||||
|
||||
<view class="debug-actions" wx:if="{{devMode}}">
|
||||
<view class="debug-label">🔧 调试工具</view>
|
||||
<button class="btn-secondary btn-sm" bindtap="onMockStart">模拟开始护理(跳过BLE)</button>
|
||||
</view>
|
||||
</view>
|
||||
</view>
|
||||
|
||||
@@ -1,16 +1,26 @@
|
||||
var ble = require('../../services/ble')
|
||||
var config = require('../../config/env')
|
||||
|
||||
Page({
|
||||
data: {
|
||||
statusBarHeight: 44,
|
||||
checking: false,
|
||||
result: null,
|
||||
error: ''
|
||||
error: '',
|
||||
devMode: false
|
||||
},
|
||||
|
||||
onBack: function () {
|
||||
wx.navigateBack({
|
||||
fail: function () {
|
||||
wx.reLaunch({ url: '/pages/index/index' })
|
||||
}
|
||||
})
|
||||
},
|
||||
|
||||
onLoad: function () {
|
||||
var app = getApp()
|
||||
this.setData({ statusBarHeight: app.globalData.statusBarHeight })
|
||||
this.setData({ statusBarHeight: app.globalData.statusBarHeight, devMode: config.__DEV__ || false })
|
||||
},
|
||||
|
||||
onShow: function () {
|
||||
@@ -47,5 +57,9 @@ Page({
|
||||
|
||||
onRetry: function () {
|
||||
this.checkWearing()
|
||||
},
|
||||
|
||||
onMockWearOk: function () {
|
||||
this.setData({ checking: false, result: 'ok' })
|
||||
}
|
||||
})
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
<view class="page">
|
||||
<view class="page-header page-header-pink" style="padding-top: {{statusBarHeight + 24}}px;">
|
||||
<view class="nav-back" bindtap="onBack">‹ 返回</view>
|
||||
<view class="page-header-title">确认佩戴</view>
|
||||
<view class="page-header-subtitle">请确保面罩贴合面部</view>
|
||||
</view>
|
||||
@@ -39,5 +40,10 @@
|
||||
</view>
|
||||
|
||||
<button wx:if="{{result === 'ok'}}" class="btn-primary mt-30" bindtap="onNext">设置护理参数</button>
|
||||
|
||||
<view class="debug-actions" wx:if="{{devMode}}">
|
||||
<view class="debug-label">🔧 调试工具</view>
|
||||
<button class="btn-secondary btn-sm" bindtap="onMockWearOk">模拟佩戴确认</button>
|
||||
</view>
|
||||
</view>
|
||||
</view>
|
||||
|
||||
在新工单中引用
屏蔽一个用户