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
这个提交包含在:
Guoguo
2026-04-28 18:32:26 -07:00
父节点 91d5937d8e
当前提交 c5f6033ccf
修改 30 个文件,包含 1509 行新增59 行删除
@@ -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' })
},
+36 -38
查看文件
@@ -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>
+42 -9
查看文件
@@ -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' })
+388
查看文件
@@ -0,0 +1,388 @@
# 服务端架构
## 概述
服务端是一个运行在腾讯云 SCFServerless 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 # 完整 DDL10 张表)
└── 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 # 微信小程序 APIcode2Session、手机号)
└── 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 | 服务器内部错误 |
---
## 部署方式
```
腾讯云 SCFWeb 函数)
├── 入口:scf_bootstrap → node scripts/local-server.js
├── 端口:9000
├── 运行时:Node.js
└── 触发方式:API Gateway HTTP 触发
```
本地开发:`npm start``node scripts/local-server.js` → 监听 `config.port`(默认 3000)。
+423
查看文件
@@ -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, ... }
```
+386
查看文件
@@ -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'`
+27
查看文件
@@ -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;
}
+4 -1
查看文件
@@ -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__
}
+25 -2
查看文件
@@ -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>
+48 -2
查看文件
@@ -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
查看文件
@@ -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
查看文件
@@ -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>
+7 -1
查看文件
@@ -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()
}
})
+5
查看文件
@@ -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>
+16 -2
查看文件
@@ -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>