From c5f6033ccfd629da14a24637dda1a031bb90df30 Mon Sep 17 00:00:00 2001 From: Guoguo Date: Tue, 28 Apr 2026 18:32:26 -0700 Subject: [PATCH] fix: improve UX and fix admin console display issues MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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 --- .../src/pages/device-detail/index.vue | 5 +- admin-console/src/pages/log/index.vue | 74 ++- .../src/pages/subscription/index.vue | 51 ++- docs/architecture/01-服务端架构.md | 388 ++++++++++++++++ docs/architecture/02-小程序架构.md | 423 ++++++++++++++++++ docs/architecture/03-管理后台架构.md | 386 ++++++++++++++++ miniprogram/app.wxss | 27 ++ miniprogram/config/env.js | 5 +- miniprogram/pages/auto-scan/auto-scan.js | 27 +- miniprogram/pages/auto-scan/auto-scan.wxml | 6 + .../pages/bind-success/bind-success.js | 8 + .../pages/bind-success/bind-success.wxml | 1 + miniprogram/pages/ble-connect/ble-connect.js | 50 ++- .../pages/ble-connect/ble-connect.wxml | 7 + miniprogram/pages/scan/scan.js | 8 + miniprogram/pages/scan/scan.wxml | 1 + .../pages/subscribe-plans/subscribe-plans.js | 8 + .../subscribe-plans/subscribe-plans.wxml | 1 + .../subscribe-prompt/subscribe-prompt.js | 6 +- .../subscribe-prompt/subscribe-prompt.wxml | 1 + .../subscribe-success/subscribe-success.js | 8 + .../subscribe-success/subscribe-success.wxml | 1 + miniprogram/pages/treating/treating.js | 8 +- miniprogram/pages/treating/treating.wxml | 5 + .../pages/treatment-done/treatment-done.js | 4 + .../pages/treatment-done/treatment-done.wxml | 1 + .../pages/treatment-setup/treatment-setup.js | 28 +- .../treatment-setup/treatment-setup.wxml | 6 + miniprogram/pages/wear-check/wear-check.js | 18 +- miniprogram/pages/wear-check/wear-check.wxml | 6 + 30 files changed, 1509 insertions(+), 59 deletions(-) create mode 100644 docs/architecture/01-服务端架构.md create mode 100644 docs/architecture/02-小程序架构.md create mode 100644 docs/architecture/03-管理后台架构.md diff --git a/admin-console/src/pages/device-detail/index.vue b/admin-console/src/pages/device-detail/index.vue index a26e6a6..70b3833 100644 --- a/admin-console/src/pages/device-detail/index.vue +++ b/admin-console/src/pages/device-detail/index.vue @@ -95,7 +95,7 @@ - + @@ -154,6 +154,9 @@ export default { } }) }, + onViewLogs() { + uni.redirectTo({ url: '/pages/log/index?device_id=' + this.deviceId }) + }, onCheckUpdate() { uni.showToast({ title: '正在检查更新...', icon: 'none' }) }, diff --git a/admin-console/src/pages/log/index.vue b/admin-console/src/pages/log/index.vue index 1bc8239..9c0f8c2 100644 --- a/admin-console/src/pages/log/index.vue +++ b/admin-console/src/pages/log/index.vue @@ -15,15 +15,15 @@ - + {{ formatDate(item.created_at) }} - {{ logTypeText(item.operator_type) }} + {{ actorLabel(item) }} - {{ item.operator_type === 1 ? '管理员' : '系统' }} - 对 {{ item.target_type }} {{ item.target_id || '' }} - 执行了 {{ item.action }} + {{ actionLabel(item.action) }} + — {{ item.detail }} + IP: {{ item.ip }} @@ -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; +} diff --git a/admin-console/src/pages/subscription/index.vue b/admin-console/src/pages/subscription/index.vue index 3f0fbe3..01c6a4e 100644 --- a/admin-console/src/pages/subscription/index.vue +++ b/admin-console/src/pages/subscription/index.vue @@ -2,6 +2,7 @@ + @@ -53,16 +54,15 @@ {{ item.nickname || item.user_id }} {{ planText(item.plan) }} ¥{{ item.amount || '-' }} - {{ formatDate(item.started_at) }} - {{ formatDate(item.expired_at) }} + {{ formatDate(item.start_time) }} + {{ formatDate(item.expire_time) }} {{ statusText(item.status) }} - 详情 - 延期 - 开通 - 续费 + 详情 + 延期 + 续费 @@ -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' }) diff --git a/docs/architecture/01-服务端架构.md b/docs/architecture/01-服务端架构.md new file mode 100644 index 0000000..e1a31c9 --- /dev/null +++ b/docs/architecture/01-服务端架构.md @@ -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)。 diff --git a/docs/architecture/02-小程序架构.md b/docs/architecture/02-小程序架构.md new file mode 100644 index 0000000..e53858a --- /dev/null +++ b/docs/architecture/02-小程序架构.md @@ -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 +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 + +``` + +### 品牌色 + +| 用途 | 色值 | +|------|------| +| 主色 | #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, ... } +``` diff --git a/docs/architecture/03-管理后台架构.md b/docs/architecture/03-管理后台架构.md new file mode 100644 index 0000000..d0f17e7 --- /dev/null +++ b/docs/architecture/03-管理后台架构.md @@ -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 + +每个页面(除登录页)都包裹在 `` 组件中: + +``` +┌─────────────────────────────────────────────┐ +│ Sidebar (220px) │ Header │ +│ │ 页面标题 管理员名 退出 │ +│ 🌸 光子美容仪 ├──────────────────────────┤ +│ 管理后台 │ │ +│ │ Content () │ +│ ▸ 仪表盘 │ │ +│ ▸ 设备管理 │ │ +│ ▸ 用户管理 │ │ +│ ▸ 订阅管理 │ │ +│ ▸ 护理记录 │ │ +│ ▸ 操作日志 │ │ +│ ▸ 系统设置 │ │ +│ │ │ +└─────────────────────────────────────────────┘ +``` + +- **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 + ├── 拼接 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 → 临时 `` 元素 → 触发下载 +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'` diff --git a/miniprogram/app.wxss b/miniprogram/app.wxss index 8f35780..bc0a557 100644 --- a/miniprogram/app.wxss +++ b/miniprogram/app.wxss @@ -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; +} diff --git a/miniprogram/config/env.js b/miniprogram/config/env.js index c2f1463..5c0aa09 100644 --- a/miniprogram/config/env.js +++ b/miniprogram/config/env.js @@ -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__ } diff --git a/miniprogram/pages/auto-scan/auto-scan.js b/miniprogram/pages/auto-scan/auto-scan.js index f87a3bf..0c525e1 100644 --- a/miniprogram/pages/auto-scan/auto-scan.js +++ b/miniprogram/pages/auto-scan/auto-scan.js @@ -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' + }) } }) diff --git a/miniprogram/pages/auto-scan/auto-scan.wxml b/miniprogram/pages/auto-scan/auto-scan.wxml index 54d6fe5..b00aa0a 100644 --- a/miniprogram/pages/auto-scan/auto-scan.wxml +++ b/miniprogram/pages/auto-scan/auto-scan.wxml @@ -1,5 +1,6 @@ + ‹ 返回 自动扫描 正在识别面部... @@ -15,6 +16,11 @@ 请将面罩对准面部 + + 🔧 调试工具 + + + 正在扫描... {{regionData.length || 3}} 个区域 diff --git a/miniprogram/pages/bind-success/bind-success.js b/miniprogram/pages/bind-success/bind-success.js index 63cdcd4..4f89b7b 100644 --- a/miniprogram/pages/bind-success/bind-success.js +++ b/miniprogram/pages/bind-success/bind-success.js @@ -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 }) diff --git a/miniprogram/pages/bind-success/bind-success.wxml b/miniprogram/pages/bind-success/bind-success.wxml index 610e7c6..8855a96 100644 --- a/miniprogram/pages/bind-success/bind-success.wxml +++ b/miniprogram/pages/bind-success/bind-success.wxml @@ -1,5 +1,6 @@ + ‹ 返回 绑定成功 设备已成功绑定 diff --git a/miniprogram/pages/ble-connect/ble-connect.js b/miniprogram/pages/ble-connect/ble-connect.js index 67783c7..320cfbd 100644 --- a/miniprogram/pages/ble-connect/ble-connect.js +++ b/miniprogram/pages/ble-connect/ble-connect.js @@ -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 || '模拟确认失败' }) + }) } }) diff --git a/miniprogram/pages/ble-connect/ble-connect.wxml b/miniprogram/pages/ble-connect/ble-connect.wxml index ea17f13..a6a3efd 100644 --- a/miniprogram/pages/ble-connect/ble-connect.wxml +++ b/miniprogram/pages/ble-connect/ble-connect.wxml @@ -1,5 +1,6 @@ + ‹ 返回 蓝牙连接 {{state === 'scanning' ? '正在搜索设备...' : state === 'connecting' ? '正在连接设备...' : state === 'binding' ? '正在绑定...' : '连接失败'}} @@ -26,5 +27,11 @@ {{error || '连接失败'}} + + + 🔧 调试工具 + + + diff --git a/miniprogram/pages/scan/scan.js b/miniprogram/pages/scan/scan.js index 8c9cc47..f326d72 100644 --- a/miniprogram/pages/scan/scan.js +++ b/miniprogram/pages/scan/scan.js @@ -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 }) diff --git a/miniprogram/pages/scan/scan.wxml b/miniprogram/pages/scan/scan.wxml index 42a571e..d77efd0 100644 --- a/miniprogram/pages/scan/scan.wxml +++ b/miniprogram/pages/scan/scan.wxml @@ -1,5 +1,6 @@ + ‹ 返回 扫码绑定 扫描设备底部二维码 diff --git a/miniprogram/pages/subscribe-plans/subscribe-plans.js b/miniprogram/pages/subscribe-plans/subscribe-plans.js index 80ff22c..1ea97d0 100644 --- a/miniprogram/pages/subscribe-plans/subscribe-plans.js +++ b/miniprogram/pages/subscribe-plans/subscribe-plans.js @@ -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 }) diff --git a/miniprogram/pages/subscribe-plans/subscribe-plans.wxml b/miniprogram/pages/subscribe-plans/subscribe-plans.wxml index 83afdc4..5b3f4cc 100644 --- a/miniprogram/pages/subscribe-plans/subscribe-plans.wxml +++ b/miniprogram/pages/subscribe-plans/subscribe-plans.wxml @@ -1,5 +1,6 @@ + ‹ 返回 订阅服务 解锁智能模式 diff --git a/miniprogram/pages/subscribe-prompt/subscribe-prompt.js b/miniprogram/pages/subscribe-prompt/subscribe-prompt.js index d74caf7..42da15c 100644 --- a/miniprogram/pages/subscribe-prompt/subscribe-prompt.js +++ b/miniprogram/pages/subscribe-prompt/subscribe-prompt.js @@ -13,6 +13,10 @@ Page({ }, onBack: function () { - wx.switchTab({ url: '/pages/index/index' }) + wx.navigateBack({ + fail: function () { + wx.reLaunch({ url: '/pages/index/index' }) + } + }) } }) diff --git a/miniprogram/pages/subscribe-prompt/subscribe-prompt.wxml b/miniprogram/pages/subscribe-prompt/subscribe-prompt.wxml index 3aadfa9..534e39c 100644 --- a/miniprogram/pages/subscribe-prompt/subscribe-prompt.wxml +++ b/miniprogram/pages/subscribe-prompt/subscribe-prompt.wxml @@ -1,5 +1,6 @@ + ‹ 返回 护理设置 试用期已结束 diff --git a/miniprogram/pages/subscribe-success/subscribe-success.js b/miniprogram/pages/subscribe-success/subscribe-success.js index e49c062..9c07ee4 100644 --- a/miniprogram/pages/subscribe-success/subscribe-success.js +++ b/miniprogram/pages/subscribe-success/subscribe-success.js @@ -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 }) diff --git a/miniprogram/pages/subscribe-success/subscribe-success.wxml b/miniprogram/pages/subscribe-success/subscribe-success.wxml index bd71771..5456556 100644 --- a/miniprogram/pages/subscribe-success/subscribe-success.wxml +++ b/miniprogram/pages/subscribe-success/subscribe-success.wxml @@ -1,5 +1,6 @@ + ‹ 返回 订阅成功 欢迎使用智能模式 diff --git a/miniprogram/pages/treating/treating.js b/miniprogram/pages/treating/treating.js index 298119f..b334f35 100644 --- a/miniprogram/pages/treating/treating.js +++ b/miniprogram/pages/treating/treating.js @@ -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() } }) diff --git a/miniprogram/pages/treating/treating.wxml b/miniprogram/pages/treating/treating.wxml index 608d918..7760e64 100644 --- a/miniprogram/pages/treating/treating.wxml +++ b/miniprogram/pages/treating/treating.wxml @@ -26,6 +26,11 @@ + + 🔧 调试工具 + + + diff --git a/miniprogram/pages/treatment-done/treatment-done.js b/miniprogram/pages/treatment-done/treatment-done.js index fbcc872..8342147 100644 --- a/miniprogram/pages/treatment-done/treatment-done.js +++ b/miniprogram/pages/treatment-done/treatment-done.js @@ -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 diff --git a/miniprogram/pages/treatment-done/treatment-done.wxml b/miniprogram/pages/treatment-done/treatment-done.wxml index dbb4c46..fc2d3c0 100644 --- a/miniprogram/pages/treatment-done/treatment-done.wxml +++ b/miniprogram/pages/treatment-done/treatment-done.wxml @@ -1,5 +1,6 @@ + ‹ 返回 护理完成 本次护理已结束 diff --git a/miniprogram/pages/treatment-setup/treatment-setup.js b/miniprogram/pages/treatment-setup/treatment-setup.js index 3198541..13ab5c8 100644 --- a/miniprogram/pages/treatment-setup/treatment-setup.js +++ b/miniprogram/pages/treatment-setup/treatment-setup.js @@ -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 + }) } }) diff --git a/miniprogram/pages/treatment-setup/treatment-setup.wxml b/miniprogram/pages/treatment-setup/treatment-setup.wxml index 578c059..97c8955 100644 --- a/miniprogram/pages/treatment-setup/treatment-setup.wxml +++ b/miniprogram/pages/treatment-setup/treatment-setup.wxml @@ -1,5 +1,6 @@ + ‹ 返回 护理设置 选择模式和区域 @@ -37,5 +38,10 @@ {{error}} + + + 🔧 调试工具 + + diff --git a/miniprogram/pages/wear-check/wear-check.js b/miniprogram/pages/wear-check/wear-check.js index c252731..ed45a40 100644 --- a/miniprogram/pages/wear-check/wear-check.js +++ b/miniprogram/pages/wear-check/wear-check.js @@ -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' }) } }) diff --git a/miniprogram/pages/wear-check/wear-check.wxml b/miniprogram/pages/wear-check/wear-check.wxml index 08274ea..90d8b7e 100644 --- a/miniprogram/pages/wear-check/wear-check.wxml +++ b/miniprogram/pages/wear-check/wear-check.wxml @@ -1,5 +1,6 @@ + ‹ 返回 确认佩戴 请确保面罩贴合面部 @@ -39,5 +40,10 @@ + + + 🔧 调试工具 + +