feat: init project with miniprogram, cloud functions and admin console

这个提交包含在:
Guoguo
2026-04-22 21:24:20 +08:00
当前提交 a84e37a6e2
修改 106 个文件,包含 5902 行新增0 行删除
+6
查看文件
@@ -0,0 +1,6 @@
node_modules/
.DS_Store
小程序及后台管理软件开发资料/
软件系统说明.docx
*.docx
cloud/sql/
+24
查看文件
@@ -0,0 +1,24 @@
# Repository Reality
- This repository currently contains only `软件系统说明.docx`; no source code, package manifest, lockfile, CI workflow, or existing agent instruction files were present when this file was written.
- Treat the Word document as the only verified source of truth. Do not invent build, test, lint, deploy, or startup commands until executable config is added to the repo.
- If code appears later, re-audit the repo and update this file from executable config before relying on the document alone.
# Verified System Scope
- Product scope from `软件系统说明.docx`: a WeChat Mini Program connects to a beauty device over BLE 5.0 GATT, cloud services run on Tencent Cloud IoT + SCF + MySQL + Redis, and the admin console is `uniapp + Vue 3 + uView Plus` deployed as H5/static hosting.
- Mini Program side is described as WeChat native (`WXML` / `WXSS` / `JS`), not `uniapp`.
- Admin console is separate from the Mini Program. Do not merge those frontend assumptions when future code is added.
# Integration Facts Worth Preserving
- BLE frame format in the document: header `0xAA 0x55`, then length, type, payload, XOR checksum.
- BLE services in the document: `FFE0` device info, `FFE1` data communication, `FFE2` OTA.
- MQTT topics in the document: `$iot/{product_id}/{device_name}/telemetry` and `$iot/{product_id}/{device_name}/event`.
- Cloud data model names called out in the document: `users`, `devices`, `bindings`, `subscriptions`, `sessions`, `treatment_records`, `pd_data`, `operation_logs`.
# Working Rules For Future Sessions
- When asked to implement code in this repo, first confirm whether source files have been added since this file was created; right now there is nothing to edit except documentation.
- If implementation details are needed beyond the document, state that they are unspecified instead of guessing architecture, package layout, or command lines.
- Prefer updating this file only after verifying new facts from checked-in config or code, not from assumptions.
+14
查看文件
@@ -0,0 +1,14 @@
<!DOCTYPE html>
<html lang="zh-CN">
<head>
<meta charset="UTF-8" />
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
<title>光子美容仪 - 管理后台</title>
<!--preload-links-->
<!--app-context-->
</head>
<body>
<div id="app"><!--app-html--></div>
<script type="module" src="/src/main.js"></script>
</body>
</html>
+25
查看文件
@@ -0,0 +1,25 @@
{
"name": "hox-admin-console",
"version": "1.0.0",
"private": true,
"scripts": {
"dev": "uni",
"build:h5": "uni build -p h5"
},
"dependencies": {
"@dcloudio/uni-app": "3.0.0-4020920240930001",
"@dcloudio/uni-app-plus": "3.0.0-4020920240930001",
"@dcloudio/uni-components": "3.0.0-4020920240930001",
"@dcloudio/uni-h5": "3.0.0-4020920240930001",
"vue": "^3.4.0",
"uview-plus": "^3.2.0",
"pinia": "^2.1.0"
},
"devDependencies": {
"@dcloudio/uni-automator": "3.0.0-4020920240930001",
"@dcloudio/uni-cli-shared": "3.0.0-4020920240930001",
"@dcloudio/vite-plugin-uni": "3.0.0-4020920240930001",
"vite": "^5.0.0",
"sass": "^1.77.0"
}
}
+21
查看文件
@@ -0,0 +1,21 @@
<script>
import { useUserStore } from './store/user'
export default {
onLaunch() {
const userStore = useUserStore()
if (!userStore.isLoggedIn()) {
uni.reLaunch({ url: '/pages/login/index' })
}
}
}
</script>
<style>
page {
background-color: #f5f5f5;
font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, 'Helvetica Neue', Arial, sans-serif;
font-size: 14px;
color: #333333;
}
</style>
+10
查看文件
@@ -0,0 +1,10 @@
import { createSSRApp } from 'vue'
import { createPinia } from 'pinia'
import App from './App.vue'
export function createApp() {
const app = createSSRApp(App)
const pinia = createPinia()
app.use(pinia)
return { app }
}
+50
查看文件
@@ -0,0 +1,50 @@
{
"pages": [
{
"path": "pages/login/index",
"style": { "navigationBarTitleText": "管理员登录", "navigationStyle": "custom" }
},
{
"path": "pages/dashboard/index",
"style": { "navigationBarTitleText": "仪表盘" }
},
{
"path": "pages/device/index",
"style": { "navigationBarTitleText": "设备管理" }
},
{
"path": "pages/device-detail/index",
"style": { "navigationBarTitleText": "设备详情" }
},
{
"path": "pages/user/index",
"style": { "navigationBarTitleText": "用户管理" }
},
{
"path": "pages/user-detail/index",
"style": { "navigationBarTitleText": "用户详情" }
},
{
"path": "pages/subscription/index",
"style": { "navigationBarTitleText": "订阅管理" }
},
{
"path": "pages/record/index",
"style": { "navigationBarTitleText": "护理记录" }
},
{
"path": "pages/log/index",
"style": { "navigationBarTitleText": "操作日志" }
},
{
"path": "pages/settings/index",
"style": { "navigationBarTitleText": "系统设置" }
}
],
"globalStyle": {
"navigationBarTextStyle": "black",
"navigationBarTitleText": "光子美容仪后台",
"navigationBarBackgroundColor": "#ffffff",
"backgroundColor": "#f5f5f5"
}
}
+150
查看文件
@@ -0,0 +1,150 @@
<template>
<view class="container">
<view class="nav-bar">
<text class="nav-item" v-for="item in navItems" :key="item.path"
:class="{ active: currentNav === item.path }" @click="onNav(item.path)">
{{ item.label }}
</text>
<text class="nav-item logout" @click="onLogout">退出</text>
</view>
<view class="stat-grid">
<view class="stat-card">
<text class="stat-value">{{ stats.user_count || 0 }}</text>
<text class="stat-label">用户数</text>
</view>
<view class="stat-card">
<text class="stat-value">{{ stats.device_count || 0 }}</text>
<text class="stat-label">设备数</text>
</view>
<view class="stat-card">
<text class="stat-value">{{ stats.active_device_count || 0 }}</text>
<text class="stat-label">激活设备</text>
</view>
<view class="stat-card">
<text class="stat-value">{{ stats.subscription_count || 0 }}</text>
<text class="stat-label">有效订阅</text>
</view>
<view class="stat-card">
<text class="stat-value">{{ stats.treatment_count || 0 }}</text>
<text class="stat-label">护理次数</text>
</view>
<view class="stat-card">
<text class="stat-value">{{ stats.today_treatment_count || 0 }}</text>
<text class="stat-label">今日护理</text>
</view>
</view>
</view>
</template>
<script>
import { get } from '../../utils/request'
import { useUserStore } from '../../store/user'
export default {
data() {
return {
stats: {},
currentNav: 'dashboard',
navItems: [
{ path: 'dashboard', label: '仪表盘' },
{ path: 'device', label: '设备' },
{ path: 'user', label: '用户' },
{ path: 'subscription', label: '订阅' },
{ path: 'record', label: '记录' },
{ path: 'log', label: '日志' },
{ path: 'settings', label: '设置' }
]
}
},
onShow() {
this.loadDashboard()
},
methods: {
async loadDashboard() {
try {
this.stats = await get('/api/v1/admin/dashboard')
} catch (e) {}
},
onNav(path) {
const routeMap = {
dashboard: '/pages/dashboard/index',
device: '/pages/device/index',
user: '/pages/user/index',
subscription: '/pages/subscription/index',
record: '/pages/record/index',
log: '/pages/log/index',
settings: '/pages/settings/index'
}
if (routeMap[path] && path !== 'dashboard') {
uni.navigateTo({ url: routeMap[path] })
}
},
onLogout() {
const userStore = useUserStore()
userStore.clearToken()
uni.reLaunch({ url: '/pages/login/index' })
}
}
}
</script>
<style scoped>
.container {
padding: 16px;
}
.nav-bar {
display: flex;
flex-wrap: wrap;
gap: 8px;
margin-bottom: 24px;
padding: 12px;
background: #ffffff;
border-radius: 8px;
}
.nav-item {
padding: 6px 16px;
font-size: 14px;
cursor: pointer;
border-radius: 4px;
color: #666666;
}
.nav-item.active {
background: #333333;
color: #ffffff;
}
.nav-item.logout {
margin-left: auto;
color: #ff4d4f;
}
.stat-grid {
display: grid;
grid-template-columns: repeat(3, 1fr);
gap: 16px;
}
.stat-card {
background: #ffffff;
border-radius: 8px;
padding: 24px 16px;
text-align: center;
}
.stat-value {
display: block;
font-size: 32px;
font-weight: 700;
color: #333333;
margin-bottom: 8px;
}
.stat-label {
font-size: 13px;
color: #999999;
}
</style>
@@ -0,0 +1,143 @@
<template>
<view class="container">
<view class="card" v-if="device">
<view class="card-title">设备信息</view>
<view class="info-row">
<text class="info-label">设备ID</text>
<text>{{ device.device_id }}</text>
</view>
<view class="info-row">
<text class="info-label">设备名称</text>
<text>{{ device.device_name || '-' }}</text>
</view>
<view class="info-row">
<text class="info-label">硬件版本</text>
<text>{{ device.hw_version || '-' }}</text>
</view>
<view class="info-row">
<text class="info-label">固件版本</text>
<text>{{ device.fw_version || '-' }}</text>
</view>
<view class="info-row">
<text class="info-label">状态</text>
<text>{{ statusMap[device.status] || '未知' }}</text>
</view>
<view class="info-row">
<text class="info-label">激活时间</text>
<text>{{ device.activated_at || '-' }}</text>
</view>
</view>
<view class="card" v-if="device && device.binding_history">
<view class="card-title">绑定记录</view>
<view v-for="(b, idx) in device.binding_history" :key="idx" class="binding-item">
<view class="info-row">
<text class="info-label">用户</text>
<text>{{ b.nickname || '-' }}</text>
</view>
<view class="info-row">
<text class="info-label">绑定时间</text>
<text>{{ formatDate(b.bound_at) }}</text>
</view>
<view class="info-row" v-if="b.unbound_at">
<text class="info-label">解绑时间</text>
<text>{{ formatDate(b.unbound_at) }}</text>
</view>
<view class="info-row">
<text class="info-label">状态</text>
<text :class="b.status === 1 ? 'text-success' : 'text-muted'">
{{ b.status === 1 ? '有效' : '已解绑' }}
</text>
</view>
<view class="divider" v-if="idx < device.binding_history.length - 1"></view>
</view>
</view>
<view class="card">
<view class="card-title">远程控制</view>
<u-button type="primary" size="small" @click="onCommand('query')">查询状态</u-button>
</view>
</view>
</template>
<script>
import { get, post } from '../../utils/request'
export default {
data() {
return {
device: null,
deviceId: '',
statusMap: { 1: '未激活', 2: '已激活', 3: '禁用', 4: '故障' }
}
},
onLoad(options) {
this.deviceId = options.device_id || ''
this.loadDevice()
},
methods: {
async loadDevice() {
try {
this.device = await get('/api/v1/admin/devices/' + this.deviceId)
} catch (e) {
uni.showToast({ title: '加载失败', icon: 'none' })
}
},
async onCommand(type) {
try {
await post('/api/v1/admin/devices/' + this.deviceId + '/command', { opcode: type })
uni.showToast({ title: '指令已发送', icon: 'success' })
} catch (e) {
uni.showToast({ title: '发送失败', icon: 'none' })
}
},
formatDate(d) {
return d ? d.slice(0, 19).replace('T', ' ') : '-'
}
}
}
</script>
<style scoped>
.container {
padding: 16px;
}
.card {
background: #ffffff;
border-radius: 8px;
padding: 16px;
margin-bottom: 16px;
}
.card-title {
font-size: 16px;
font-weight: 600;
margin-bottom: 12px;
}
.info-row {
display: flex;
justify-content: space-between;
padding: 8px 0;
font-size: 14px;
}
.info-label {
color: #999999;
}
.divider {
height: 1px;
background: #eeeeee;
margin: 8px 0;
}
.text-success {
color: #52c41a;
}
.text-muted {
color: #999999;
}
</style>
+116
查看文件
@@ -0,0 +1,116 @@
<template>
<view class="container">
<view class="toolbar">
<u-search v-model="keyword" placeholder="搜索设备ID或名称" @search="onSearch" @clear="onSearch" />
</view>
<view class="table-wrap">
<u-table>
<u-tr>
<u-th>设备ID</u-th>
<u-th>名称</u-th>
<u-th>状态</u-th>
<u-th>绑定用户</u-th>
<u-th>激活时间</u-th>
</u-tr>
<u-tr v-for="item in devices" :key="item.device_id" @click="onDetail(item.device_id)">
<u-td>{{ item.device_id }}</u-td>
<u-td>{{ item.device_name || '-' }}</u-td>
<u-td>
<text :class="item.status === 2 ? 'text-success' : 'text-muted'">
{{ statusMap[item.status] || '未知' }}
</text>
</u-td>
<u-td>{{ item.bound_user || '-' }}</u-td>
<u-td>{{ formatDate(item.activated_at) }}</u-td>
</u-tr>
</u-table>
</view>
<view class="pagination">
<u-button size="small" :disabled="page <= 1" @click="onPage(page - 1)">上一页</u-button>
<text class="page-info"> {{ page }} / {{ totalPages }} </text>
<u-button size="small" :disabled="page >= totalPages" @click="onPage(page + 1)">下一页</u-button>
</view>
</view>
</template>
<script>
import { get } from '../../utils/request'
export default {
data() {
return {
devices: [],
total: 0,
page: 1,
pageSize: 20,
keyword: '',
statusMap: { 1: '未激活', 2: '已激活', 3: '禁用', 4: '故障' }
}
},
computed: {
totalPages() {
return Math.ceil(this.total / this.pageSize) || 1
}
},
onShow() {
this.loadDevices()
},
methods: {
async loadDevices() {
try {
const data = await get('/api/v1/admin/devices', { page: this.page, page_size: this.pageSize })
this.devices = data.records || []
this.total = data.total || 0
} catch (e) {}
},
onSearch() {
this.page = 1
this.loadDevices()
},
onPage(p) {
this.page = p
this.loadDevices()
},
onDetail(deviceId) {
uni.navigateTo({ url: '/pages/device-detail/index?device_id=' + deviceId })
},
formatDate(d) {
return d ? d.slice(0, 10) : '-'
}
}
}
</script>
<style scoped>
.container {
padding: 16px;
}
.toolbar {
margin-bottom: 16px;
}
.pagination {
display: flex;
align-items: center;
justify-content: center;
gap: 16px;
margin-top: 16px;
padding: 12px;
}
.page-info {
font-size: 13px;
color: #999999;
}
.text-success {
color: #52c41a;
}
.text-muted {
color: #999999;
}
</style>
+112
查看文件
@@ -0,0 +1,112 @@
<template>
<view class="container">
<view class="toolbar">
<view class="filter-row">
<u-input v-model="filters.type" placeholder="操作类型" class="filter-input" />
<u-input v-model="filters.device_id" placeholder="设备ID" class="filter-input" />
<u-button type="primary" size="small" @click="onSearch">查询</u-button>
</view>
</view>
<view class="table-wrap">
<u-table>
<u-tr>
<u-th>操作者</u-th>
<u-th>对象类型</u-th>
<u-th>对象ID</u-th>
<u-th>操作</u-th>
<u-th>时间</u-th>
</u-tr>
<u-tr v-for="item in logs" :key="item.id">
<u-td>{{ item.operator_type === 1 ? '管理员' : '系统' }}</u-td>
<u-td>{{ item.target_type }}</u-td>
<u-td>{{ item.target_id || '-' }}</u-td>
<u-td>{{ item.action }}</u-td>
<u-td>{{ formatDate(item.created_at) }}</u-td>
</u-tr>
</u-table>
</view>
<view class="pagination">
<u-button size="small" :disabled="page <= 1" @click="onPage(page - 1)">上一页</u-button>
<text class="page-info"> {{ page }} / {{ totalPages }} </text>
<u-button size="small" :disabled="page >= totalPages" @click="onPage(page + 1)">下一页</u-button>
</view>
</view>
</template>
<script>
import { get } from '../../utils/request'
export default {
data() {
return {
logs: [],
total: 0,
page: 1,
pageSize: 20,
filters: { type: '', device_id: '' }
}
},
computed: {
totalPages() {
return Math.ceil(this.total / this.pageSize) || 1
}
},
onShow() {
this.loadLogs()
},
methods: {
async loadLogs() {
try {
var params = { page: this.page, page_size: this.pageSize }
if (this.filters.type) params.type = this.filters.type
if (this.filters.device_id) params.device_id = this.filters.device_id
const data = await get('/api/v1/admin/logs', params)
this.logs = data.records || []
this.total = data.total || 0
} catch (e) {}
},
onSearch() {
this.page = 1
this.loadLogs()
},
onPage(p) {
this.page = p
this.loadLogs()
},
formatDate(d) {
return d ? d.slice(0, 19).replace('T', ' ') : '-'
}
}
}
</script>
<style scoped>
.container {
padding: 16px;
}
.filter-row {
display: flex;
gap: 8px;
align-items: center;
}
.filter-input {
width: 120px;
}
.pagination {
display: flex;
align-items: center;
justify-content: center;
gap: 16px;
margin-top: 16px;
}
.page-info {
font-size: 13px;
color: #999999;
}
</style>
+86
查看文件
@@ -0,0 +1,86 @@
<template>
<view class="login-page">
<view class="login-card">
<view class="login-title">光子美容仪</view>
<view class="login-subtitle">管理后台</view>
<u-form :model="form" ref="formRef">
<u-form-item>
<u-input v-model="form.username" placeholder="用户名" border="bottom" />
</u-form-item>
<u-form-item>
<u-input v-model="form.password" placeholder="密码" type="password" border="bottom" />
</u-form-item>
</u-form>
<u-button type="primary" :loading="loading" @click="onLogin" :disabled="loading" class="login-btn">
登录
</u-button>
</view>
</view>
</template>
<script>
import { useUserStore } from '../../store/user'
export default {
data() {
return {
form: { username: '', password: '' },
loading: false
}
},
methods: {
async onLogin() {
if (!this.form.username || !this.form.password) {
uni.showToast({ title: '请输入用户名和密码', icon: 'none' })
return
}
this.loading = true
try {
const userStore = useUserStore()
await userStore.login(this.form)
uni.reLaunch({ url: '/pages/dashboard/index' })
} catch (e) {
uni.showToast({ title: e.message || '登录失败', icon: 'none' })
} finally {
this.loading = false
}
}
}
}
</script>
<style scoped>
.login-page {
min-height: 100vh;
display: flex;
align-items: center;
justify-content: center;
background: linear-gradient(135deg, #333333 0%, #666666 100%);
}
.login-card {
width: 400px;
background: #ffffff;
border-radius: 12px;
padding: 48px 32px;
}
.login-title {
font-size: 28px;
font-weight: 700;
text-align: center;
margin-bottom: 8px;
}
.login-subtitle {
font-size: 16px;
color: #999999;
text-align: center;
margin-bottom: 32px;
}
.login-btn {
margin-top: 24px;
background-color: #333333;
}
</style>
+112
查看文件
@@ -0,0 +1,112 @@
<template>
<view class="container">
<view class="toolbar flex-between">
<text class="total-text"> {{ total }} 条记录</text>
</view>
<view class="table-wrap">
<u-table>
<u-tr>
<u-th>会话ID</u-th>
<u-th>用户ID</u-th>
<u-th>区域</u-th>
<u-th>时长</u-th>
<u-th>时间</u-th>
</u-tr>
<u-tr v-for="item in records" :key="item.record_id">
<u-td>{{ item.session_id ? item.session_id.slice(0, 8) : '-' }}</u-td>
<u-td>{{ item.user_id }}</u-td>
<u-td>{{ formatRegions(item.regions) }}</u-td>
<u-td>{{ Math.floor((item.total_duration_ms || 0) / 60000) }}分钟</u-td>
<u-td>{{ formatDate(item.start_time) }}</u-td>
</u-tr>
</u-table>
</view>
<view class="pagination">
<u-button size="small" :disabled="page <= 1" @click="onPage(page - 1)">上一页</u-button>
<text class="page-info"> {{ page }} / {{ totalPages }} </text>
<u-button size="small" :disabled="page >= totalPages" @click="onPage(page + 1)">下一页</u-button>
</view>
</view>
</template>
<script>
import { get } from '../../utils/request'
var REGION_MAP = {
1: '左脸', 2: '右脸', 4: '额头', 8: '下巴', 16: '鼻', 32: '左眼', 64: '右眼'
}
export default {
data() {
return {
records: [],
total: 0,
page: 1,
pageSize: 20
}
},
computed: {
totalPages() {
return Math.ceil(this.total / this.pageSize) || 1
}
},
onShow() {
this.loadRecords()
},
methods: {
async loadRecords() {
try {
const data = await get('/api/v1/admin/logs', {
type: 'treatment',
page: this.page,
page_size: this.pageSize
})
this.records = data.records || []
this.total = data.total || 0
} catch (e) {}
},
onPage(p) {
this.page = p
this.loadRecords()
},
formatRegions(mask) {
if (!mask) return '-'
var names = []
var bits = [1, 2, 4, 8, 16, 32, 64]
for (var i = 0; i < bits.length; i++) {
if (mask & bits[i]) names.push(REGION_MAP[bits[i]])
}
return names.join(',') || '-'
},
formatDate(d) {
return d ? d.slice(0, 10) : '-'
}
}
}
</script>
<style scoped>
.container {
padding: 16px;
}
.total-text {
font-size: 13px;
color: #999999;
}
.pagination {
display: flex;
align-items: center;
justify-content: center;
gap: 16px;
margin-top: 16px;
}
.page-info {
font-size: 13px;
color: #999999;
}
</style>
@@ -0,0 +1,94 @@
<template>
<view class="container">
<view class="card">
<view class="card-title">系统设置</view>
<view class="setting-section">
<view class="section-label">管理员管理</view>
<view class="info-row">
<text>当前仅支持通过数据库管理管理员账号</text>
</view>
</view>
<view class="divider"></view>
<view class="setting-section">
<view class="section-label">环境变量</view>
<view class="info-row">
<text class="info-label">API地址</text>
<text>https://api.lightmask.com</text>
</view>
<view class="info-row">
<text class="info-label">MQTT Broker</text>
<text>mqtt://iotcloud.tencent.com:8883</text>
</view>
<view class="info-row">
<text class="info-label">JWT有效期</text>
<text>7</text>
</view>
</view>
</view>
<view class="card">
<view class="card-title">关于</view>
<view class="info-row">
<text class="info-label">系统版本</text>
<text>1.0.0</text>
</view>
<view class="info-row">
<text class="info-label">协议版本</text>
<text>v1.0</text>
</view>
</view>
</view>
</template>
<script>
export default {}
</script>
<style scoped>
.container {
padding: 16px;
}
.card {
background: #ffffff;
border-radius: 8px;
padding: 16px;
margin-bottom: 16px;
}
.card-title {
font-size: 16px;
font-weight: 600;
margin-bottom: 16px;
}
.setting-section {
margin-bottom: 16px;
}
.section-label {
font-size: 14px;
font-weight: 500;
margin-bottom: 12px;
}
.info-row {
display: flex;
justify-content: space-between;
padding: 8px 0;
font-size: 14px;
}
.info-label {
color: #999999;
}
.divider {
height: 1px;
background: #eeeeee;
margin: 16px 0;
}
</style>
@@ -0,0 +1,165 @@
<template>
<view class="container">
<view class="toolbar flex-between">
<text class="total-text"> {{ total }} </text>
<u-button type="primary" size="small" @click="showCreate = true">创建订阅</u-button>
</view>
<view class="table-wrap">
<u-table>
<u-tr>
<u-th>用户ID</u-th>
<u-th>昵称</u-th>
<u-th>类型</u-th>
<u-th>方案</u-th>
<u-th>状态</u-th>
<u-th>到期时间</u-th>
</u-tr>
<u-tr v-for="item in subscriptions" :key="item.id">
<u-td>{{ item.user_id }}</u-td>
<u-td>{{ item.nickname || '-' }}</u-td>
<u-td>{{ subTypeMap[item.type] || '-' }}</u-td>
<u-td>{{ item.plan || '-' }}</u-td>
<u-td>
<text :class="item.status === 1 ? 'text-success' : 'text-muted'">
{{ subStatusMap[item.status] || '-' }}
</text>
</u-td>
<u-td>{{ formatDate(item.expired_at) }}</u-td>
</u-tr>
</u-table>
</view>
<view class="pagination">
<u-button size="small" :disabled="page <= 1" @click="onPage(page - 1)">上一页</u-button>
<text class="page-info"> {{ page }} / {{ totalPages }} </text>
<u-button size="small" :disabled="page >= totalPages" @click="onPage(page + 1)">下一页</u-button>
</view>
<u-popup :show="showCreate" @close="showCreate = false" mode="center">
<view class="create-form">
<view class="form-title">创建订阅</view>
<u-form :model="createForm">
<u-form-item label="用户ID">
<u-input v-model="createForm.user_id" placeholder="输入用户ID" />
</u-form-item>
<u-form-item label="方案">
<u-input v-model="createForm.plan" placeholder="monthly/quarterly/yearly" />
</u-form-item>
<u-form-item label="天数">
<u-input v-model="createForm.days" placeholder="订阅天数" type="number" />
</u-form-item>
</u-form>
<u-button type="primary" @click="onCreate" :loading="creating">创建</u-button>
</view>
</u-popup>
</view>
</template>
<script>
import { get, post } from '../../utils/request'
export default {
data() {
return {
subscriptions: [],
total: 0,
page: 1,
pageSize: 20,
showCreate: false,
creating: false,
createForm: { user_id: '', plan: 'monthly', days: 30 },
subTypeMap: { 1: '试用', 2: '正式', 3: '赠送' },
subStatusMap: { 1: '有效', 2: '已过期', 3: '已停用' }
}
},
computed: {
totalPages() {
return Math.ceil(this.total / this.pageSize) || 1
}
},
onShow() {
this.loadSubscriptions()
},
methods: {
async loadSubscriptions() {
try {
const data = await get('/api/v1/admin/subscriptions', { page: this.page, page_size: this.pageSize })
this.subscriptions = data.records || []
this.total = data.total || 0
} catch (e) {}
},
onPage(p) {
this.page = p
this.loadSubscriptions()
},
async onCreate() {
this.creating = true
try {
await post('/api/v1/admin/subscriptions', {
user_id: parseInt(this.createForm.user_id),
plan: this.createForm.plan,
days: parseInt(this.createForm.days)
})
this.showCreate = false
this.loadSubscriptions()
uni.showToast({ title: '创建成功', icon: 'success' })
} catch (e) {
uni.showToast({ title: '创建失败', icon: 'none' })
} finally {
this.creating = false
}
},
formatDate(d) {
return d ? d.slice(0, 10) : '-'
}
}
}
</script>
<style scoped>
.container {
padding: 16px;
}
.toolbar {
margin-bottom: 16px;
}
.total-text {
font-size: 13px;
color: #999999;
}
.pagination {
display: flex;
align-items: center;
justify-content: center;
gap: 16px;
margin-top: 16px;
}
.page-info {
font-size: 13px;
color: #999999;
}
.create-form {
padding: 24px;
width: 400px;
}
.form-title {
font-size: 18px;
font-weight: 600;
margin-bottom: 16px;
}
.text-success {
color: #52c41a;
}
.text-muted {
color: #999999;
}
</style>
@@ -0,0 +1,152 @@
<template>
<view class="container">
<view class="card" v-if="user">
<view class="card-title">用户信息</view>
<view class="info-row">
<text class="info-label">用户ID</text>
<text>{{ user.user_id }}</text>
</view>
<view class="info-row">
<text class="info-label">昵称</text>
<text>{{ user.nickname || '-' }}</text>
</view>
<view class="info-row">
<text class="info-label">手机号</text>
<text>{{ user.phone || '-' }}</text>
</view>
<view class="info-row">
<text class="info-label">注册时间</text>
<text>{{ formatDate(user.created_at) }}</text>
</view>
</view>
<view class="card" v-if="user && user.devices">
<view class="card-title">绑定设备</view>
<view v-for="(d, idx) in user.devices" :key="idx" class="device-item">
<view class="info-row">
<text class="info-label">设备</text>
<text>{{ d.device_name || d.device_id }}</text>
</view>
<view class="info-row">
<text class="info-label">绑定时间</text>
<text>{{ formatDate(d.bound_at) }}</text>
</view>
<view class="divider" v-if="idx < user.devices.length - 1"></view>
</view>
<view v-if="user.devices.length === 0" class="text-muted">暂无绑定设备</view>
</view>
<view class="card" v-if="user && user.subscriptions">
<view class="card-title">订阅记录</view>
<view v-for="(s, idx) in user.subscriptions" :key="idx">
<view class="info-row">
<text class="info-label">类型</text>
<text>{{ subTypeMap[s.type] || '未知' }}</text>
</view>
<view class="info-row">
<text class="info-label">状态</text>
<text :class="s.status === 1 ? 'text-success' : 'text-muted'">
{{ subStatusMap[s.status] || '未知' }}
</text>
</view>
<view class="info-row">
<text class="info-label">有效期</text>
<text>{{ formatDate(s.started_at) }} ~ {{ formatDate(s.expired_at) }}</text>
</view>
<view class="divider" v-if="idx < user.subscriptions.length - 1"></view>
</view>
<view v-if="user.subscriptions.length === 0" class="text-muted">暂无订阅</view>
</view>
<view class="card" v-if="user && user.recent_treatments">
<view class="card-title">最近护理</view>
<view v-for="(t, idx) in user.recent_treatments" :key="idx">
<view class="info-row">
<text class="info-label">时间</text>
<text>{{ formatDate(t.started_at) }}</text>
</view>
<view class="info-row">
<text class="info-label">时长</text>
<text>{{ Math.floor((t.total_duration_ms || 0) / 60000) }}分钟</text>
</view>
<view class="divider" v-if="idx < user.recent_treatments.length - 1"></view>
</view>
<view v-if="user.recent_treatments.length === 0" class="text-muted">暂无记录</view>
</view>
</view>
</template>
<script>
import { get } from '../../utils/request'
export default {
data() {
return {
user: null,
userId: '',
subTypeMap: { 1: '试用', 2: '正式', 3: '赠送' },
subStatusMap: { 1: '有效', 2: '已过期', 3: '已停用' }
}
},
onLoad(options) {
this.userId = options.user_id || ''
this.loadUser()
},
methods: {
async loadUser() {
try {
this.user = await get('/api/v1/admin/users/' + this.userId)
} catch (e) {
uni.showToast({ title: '加载失败', icon: 'none' })
}
},
formatDate(d) {
return d ? d.slice(0, 10) : '-'
}
}
}
</script>
<style scoped>
.container {
padding: 16px;
}
.card {
background: #ffffff;
border-radius: 8px;
padding: 16px;
margin-bottom: 16px;
}
.card-title {
font-size: 16px;
font-weight: 600;
margin-bottom: 12px;
}
.info-row {
display: flex;
justify-content: space-between;
padding: 8px 0;
font-size: 14px;
}
.info-label {
color: #999999;
}
.divider {
height: 1px;
background: #eeeeee;
margin: 8px 0;
}
.text-success {
color: #52c41a;
}
.text-muted {
color: #999999;
}
</style>
+102
查看文件
@@ -0,0 +1,102 @@
<template>
<view class="container">
<view class="toolbar">
<u-search v-model="keyword" placeholder="搜索用户昵称" @search="onSearch" @clear="onSearch" />
</view>
<view class="table-wrap">
<u-table>
<u-tr>
<u-th>用户ID</u-th>
<u-th>昵称</u-th>
<u-th>设备数</u-th>
<u-th>订阅到期</u-th>
<u-th>注册时间</u-th>
</u-tr>
<u-tr v-for="item in users" :key="item.user_id" @click="onDetail(item.user_id)">
<u-td>{{ item.user_id }}</u-td>
<u-td>{{ item.nickname || '-' }}</u-td>
<u-td>{{ item.device_count || 0 }}</u-td>
<u-td>{{ formatDate(item.subscription_expire) }}</u-td>
<u-td>{{ formatDate(item.created_at) }}</u-td>
</u-tr>
</u-table>
</view>
<view class="pagination">
<u-button size="small" :disabled="page <= 1" @click="onPage(page - 1)">上一页</u-button>
<text class="page-info"> {{ page }} / {{ totalPages }} </text>
<u-button size="small" :disabled="page >= totalPages" @click="onPage(page + 1)">下一页</u-button>
</view>
</view>
</template>
<script>
import { get } from '../../utils/request'
export default {
data() {
return {
users: [],
total: 0,
page: 1,
pageSize: 20,
keyword: ''
}
},
computed: {
totalPages() {
return Math.ceil(this.total / this.pageSize) || 1
}
},
onShow() {
this.loadUsers()
},
methods: {
async loadUsers() {
try {
const data = await get('/api/v1/admin/users', { page: this.page, page_size: this.pageSize })
this.users = data.records || []
this.total = data.total || 0
} catch (e) {}
},
onSearch() {
this.page = 1
this.loadUsers()
},
onPage(p) {
this.page = p
this.loadUsers()
},
onDetail(userId) {
uni.navigateTo({ url: '/pages/user-detail/index?user_id=' + userId })
},
formatDate(d) {
return d ? d.slice(0, 10) : '-'
}
}
}
</script>
<style scoped>
.container {
padding: 16px;
}
.toolbar {
margin-bottom: 16px;
}
.pagination {
display: flex;
align-items: center;
justify-content: center;
gap: 16px;
margin-top: 16px;
}
.page-info {
font-size: 13px;
color: #999999;
}
</style>
+37
查看文件
@@ -0,0 +1,37 @@
import { defineStore } from 'pinia'
import { ref } from 'vue'
import { post } from '../utils/request'
export const useUserStore = defineStore('user', () => {
const token = ref(uni.getStorageSync('admin_token') || '')
const adminInfo = ref(null)
function setToken(val) {
token.value = val
uni.setStorageSync('admin_token', val)
}
function clearToken() {
token.value = ''
adminInfo.value = null
uni.removeStorageSync('admin_token')
}
async function login(credentials) {
const data = await post('/api/v1/admin/login', credentials)
setToken(data.token)
adminInfo.value = {
admin_id: data.admin_id,
username: data.username,
real_name: data.real_name,
role: data.role
}
return data
}
function isLoggedIn() {
return !!token.value
}
return { token, adminInfo, setToken, clearToken, login, isLoggedIn }
})
+46
查看文件
@@ -0,0 +1,46 @@
const BASE_URL = 'https://api.lightmask.com'
function request(options) {
const token = uni.getStorageSync('admin_token')
return new Promise((resolve, reject) => {
uni.request({
url: BASE_URL + options.url,
method: options.method || 'GET',
data: options.data || {},
header: {
'Authorization': token ? 'Bearer ' + token : '',
'Content-Type': 'application/json',
...options.header
},
success(res) {
if (res.data && res.data.code === 0) {
resolve(res.data.data)
} else if (res.data && (res.data.code === 1001 || res.data.code === 1002)) {
uni.removeStorageSync('admin_token')
uni.reLaunch({ url: '/pages/login/index' })
reject(res.data)
} else {
reject(res.data || { code: -1, message: '请求失败' })
}
},
fail(err) {
reject({ code: -1, message: err.errMsg || '网络异常' })
}
})
})
}
function get(url, data) {
return request({ url, method: 'GET', data })
}
function post(url, data) {
return request({ url, method: 'POST', data })
}
function put(url, data) {
return request({ url, method: 'PUT', data })
}
export { request, get, post, put, BASE_URL }
+6
查看文件
@@ -0,0 +1,6 @@
import { defineConfig } from 'vite'
import uni from '@dcloudio/vite-plugin-uni'
export default defineConfig({
plugins: [uni()]
})
+62
查看文件
@@ -0,0 +1,62 @@
var jwt = require('jsonwebtoken')
var JWT_SECRET = process.env.JWT_SECRET || 'hox_jwt_secret_change_in_production'
var JWT_EXPIRES_IN = '7d'
function generateToken(userId, openid) {
return jwt.sign(
{ user_id: userId, openid: openid },
JWT_SECRET,
{ expiresIn: JWT_EXPIRES_IN }
)
}
function verifyToken(token) {
try {
return jwt.verify(token, JWT_SECRET)
} catch (e) {
return null
}
}
function generateAdminToken(adminId, username, role) {
return jwt.sign(
{ admin_id: adminId, username: username, role: role, type: 'admin' },
JWT_SECRET,
{ expiresIn: JWT_EXPIRES_IN }
)
}
function extractToken(event) {
var header = event.headers || {}
var auth = header['Authorization'] || header['authorization'] || header['authorization'] || ''
if (auth.startsWith('Bearer ')) {
return auth.substring(7)
}
return null
}
function extractUser(event) {
var token = extractToken(event)
if (!token) return null
var decoded = verifyToken(token)
if (!decoded || decoded.type === 'admin') return null
return decoded
}
function extractAdmin(event) {
var token = extractToken(event)
if (!token) return null
var decoded = verifyToken(token)
if (!decoded || decoded.type !== 'admin') return null
return decoded
}
module.exports = {
generateToken: generateToken,
verifyToken: verifyToken,
generateAdminToken: generateAdminToken,
extractToken: extractToken,
extractUser: extractUser,
extractAdmin: extractAdmin
}
+45
查看文件
@@ -0,0 +1,45 @@
var mysql = require('mysql2/promise')
var pool = null
function getPool() {
if (pool) return pool
pool = mysql.createPool({
host: process.env.DB_HOST || 'localhost',
port: parseInt(process.env.DB_PORT || '3306'),
user: process.env.DB_USER || 'root',
password: process.env.DB_PASSWORD || '',
database: process.env.DB_NAME || 'hox',
waitForConnections: true,
connectionLimit: 10,
charset: 'utf8mb4'
})
return pool
}
async function query(sql, params) {
var p = getPool()
var [rows] = await p.execute(sql, params)
return rows
}
async function insert(sql, params) {
var p = getPool()
var [result] = await p.execute(sql, params)
return result
}
async function update(sql, params) {
var p = getPool()
var [result] = await p.execute(sql, params)
return result
}
module.exports = {
query: query,
insert: insert,
update: update,
getPool: getPool
}
+27
查看文件
@@ -0,0 +1,27 @@
var db = require('./db')
async function writeLog(options) {
try {
await db.insert(
'INSERT INTO operation_logs (operator_type, operator_id, target_type, target_id, action, before_snapshot, after_snapshot, ip_address, user_agent, remark) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?)',
[
options.operator_type || 2,
options.operator_id || null,
options.target_type || '',
options.target_id || null,
options.action || '',
options.before_snapshot ? JSON.stringify(options.before_snapshot) : null,
options.after_snapshot ? JSON.stringify(options.after_snapshot) : null,
options.ip_address || null,
options.user_agent || null,
options.remark || null
]
)
} catch (e) {
console.error('writeLog failed:', e.message)
}
}
module.exports = {
writeLog: writeLog
}
+47
查看文件
@@ -0,0 +1,47 @@
var RESPONSE = {
success: function (data, message) {
return {
code: 0,
message: message || 'success',
data: data || null
}
},
error: function (code, message) {
return {
code: code || -1,
message: message || 'error',
data: null
}
},
paginate: function (list, total, page, pageSize) {
return {
code: 0,
message: 'success',
data: {
total: total,
page: page,
page_size: pageSize,
records: list
}
}
}
}
var ERROR_CODES = {
INVALID_TOKEN: { code: 1001, message: 'invalid_token' },
TOKEN_EXPIRED: { code: 1002, message: 'token_expired' },
PERMISSION_DENIED: { code: 1003, message: 'permission_denied' },
USER_NOT_FOUND: { code: 1004, message: 'user_not_found' },
DEVICE_NOT_FOUND: { code: 1005, message: 'device_not_found' },
DEVICE_NOT_BOUND: { code: 1006, message: 'device_not_bound' },
SUBSCRIPTION_REQUIRED: { code: 1007, message: 'subscription_required' },
SUBSCRIPTION_EXPIRED: { code: 1008, message: 'subscription_expired' },
PARAM_ERROR: { code: 2001, message: 'param_error' },
NETWORK_ERROR: { code: 2002, message: 'network_error' },
SERVER_ERROR: { code: 3001, message: 'server_error' }
}
module.exports = {
RESPONSE: RESPONSE,
ERROR_CODES: ERROR_CODES
}
+308
查看文件
@@ -0,0 +1,308 @@
var db = require('../../common/db')
var auth = require('../../common/auth')
var log = require('../../common/log')
var RESPONSE = require('../../common/response').RESPONSE
var ERROR_CODES = require('../../common/response').ERROR_CODES
var bcrypt = require('bcryptjs')
exports.main_handler = async function (event) {
try {
var method = event.httpMethod || 'GET'
var path = event.path || ''
var pathParams = event.pathParameters || {}
var body = parseBody(event)
if (method === 'POST' && path === '/api/v1/admin/login') {
return await adminLogin(body, event)
}
var admin = auth.extractAdmin(event)
if (!admin) return RESPONSE.error(ERROR_CODES.INVALID_TOKEN.code, ERROR_CODES.INVALID_TOKEN.message)
if (method === 'GET' && path === '/api/v1/admin/dashboard') {
return await getDashboard()
}
if (method === 'GET' && path === '/api/v1/admin/devices' && !pathParams.device_id) {
return await listDevices(body)
}
if (method === 'GET' && pathParams.device_id && !path.match(/\/command$/)) {
return await getDeviceDetail(pathParams.device_id)
}
if (method === 'POST' && path.match(/\/command$/)) {
return await sendDeviceCommand(pathParams.device_id, body)
}
if (method === 'GET' && path === '/api/v1/admin/users' && !pathParams.user_id) {
return await listUsers(body)
}
if (method === 'GET' && pathParams.user_id && path.indexOf('/admin/users/') !== -1) {
return await getUserDetail(pathParams.user_id)
}
if (method === 'GET' && path === '/api/v1/admin/subscriptions') {
return await listSubscriptions(body)
}
if (method === 'POST' && path === '/api/v1/admin/subscriptions') {
return await createSubscription(event, body)
}
if (method === 'GET' && path === '/api/v1/admin/logs') {
return await getLogs(body)
}
return RESPONSE.error(ERROR_CODES.PARAM_ERROR.code, 'unknown route')
} catch (err) {
console.error('admin error:', err)
return RESPONSE.error(ERROR_CODES.SERVER_ERROR.code, err.message)
}
}
async function adminLogin(body, event) {
if (!body.username || !body.password) {
return RESPONSE.error(ERROR_CODES.PARAM_ERROR.code, 'missing username or password')
}
var admins = await db.query(
'SELECT id, username, password_hash, role, real_name FROM admins WHERE username = ? AND status = 1 AND deleted_at IS NULL LIMIT 1',
[body.username]
)
if (admins.length === 0) {
return RESPONSE.error(ERROR_CODES.PERMISSION_DENIED.code, 'invalid credentials')
}
var admin = admins[0]
var valid = bcrypt.compareSync(body.password, admin.password_hash)
if (!valid) {
return RESPONSE.error(ERROR_CODES.PERMISSION_DENIED.code, 'invalid credentials')
}
await db.update(
'UPDATE admins SET last_login_at = NOW() WHERE id = ?',
[admin.id]
)
var token = auth.generateAdminToken(admin.id, admin.username, admin.role)
await log.writeLog({
operator_type: 1,
operator_id: admin.id,
target_type: 'admin',
target_id: String(admin.id),
action: 'login',
ip_address: getClientIp(event)
})
return RESPONSE.success({
token: token,
admin_id: String(admin.id),
username: admin.username,
real_name: admin.real_name,
role: admin.role
})
}
async function getDashboard() {
var userCount = await db.query('SELECT COUNT(*) AS cnt FROM users WHERE deleted_at IS NULL')
var deviceCount = await db.query('SELECT COUNT(*) AS cnt FROM devices WHERE deleted_at IS NULL')
var activeDevices = await db.query('SELECT COUNT(*) AS cnt FROM devices WHERE status = 2 AND deleted_at IS NULL')
var subCount = await db.query('SELECT COUNT(*) AS cnt FROM subscriptions WHERE status = 1 AND expired_at > NOW()')
var trialCount = await db.query('SELECT COUNT(*) AS cnt FROM subscriptions WHERE type = 1')
var treatmentCount = await db.query('SELECT COUNT(*) AS cnt FROM treatment_records')
var todayTreatments = await db.query(
"SELECT COUNT(*) AS cnt FROM treatment_records WHERE DATE(created_at) = CURDATE()"
)
return RESPONSE.success({
user_count: userCount[0].cnt,
device_count: deviceCount[0].cnt,
active_device_count: activeDevices[0].cnt,
subscription_count: subCount[0].cnt,
trial_count: trialCount[0].cnt,
treatment_count: treatmentCount[0].cnt,
today_treatment_count: todayTreatments[0].cnt
})
}
async function listDevices(body) {
var page = parseInt(body.page) || 1
var pageSize = parseInt(body.page_size) || 20
var offset = (page - 1) * pageSize
var total = await db.query('SELECT COUNT(*) AS cnt FROM devices WHERE deleted_at IS NULL')
var devices = await db.query(
'SELECT d.id, d.device_id, d.device_name, d.device_type, d.hw_version, d.fw_version, d.status, d.activated_at, d.created_at, ' +
'u.nickname AS bound_user, b.bound_at ' +
'FROM devices d ' +
'LEFT JOIN bindings b ON d.id = b.device_id AND b.status = 1 ' +
'LEFT JOIN users u ON b.user_id = u.id AND u.deleted_at IS NULL ' +
'WHERE d.deleted_at IS NULL ' +
'ORDER BY d.created_at DESC LIMIT ? OFFSET ?',
[pageSize, offset]
)
return RESPONSE.paginate(devices, total[0].cnt, page, pageSize)
}
async function getDeviceDetail(deviceId) {
var devices = await db.query(
'SELECT * FROM devices WHERE device_id = ? AND deleted_at IS NULL LIMIT 1',
[deviceId]
)
if (devices.length === 0) {
return RESPONSE.error(ERROR_CODES.DEVICE_NOT_FOUND.code, ERROR_CODES.DEVICE_NOT_FOUND.message)
}
var bindings = await db.query(
'SELECT b.*, u.nickname, u.avatar_url FROM bindings b JOIN users u ON b.user_id = u.id WHERE b.device_id = ? ORDER BY b.bound_at DESC',
[devices[0].id]
)
return RESPONSE.success(Object.assign({}, devices[0], { binding_history: bindings }))
}
async function sendDeviceCommand(deviceId, body) {
var devices = await db.query(
'SELECT id, device_id, product_id, device_name FROM devices WHERE device_id = ? AND deleted_at IS NULL LIMIT 1',
[deviceId]
)
if (devices.length === 0) {
return RESPONSE.error(ERROR_CODES.DEVICE_NOT_FOUND.code, ERROR_CODES.DEVICE_NOT_FOUND.message)
}
return RESPONSE.success({ message: 'command queued', device_id: deviceId, command: body })
}
async function listUsers(body) {
var page = parseInt(body.page) || 1
var pageSize = parseInt(body.page_size) || 20
var offset = (page - 1) * pageSize
var total = await db.query('SELECT COUNT(*) AS cnt FROM users WHERE deleted_at IS NULL')
var users = await db.query(
'SELECT u.id AS user_id, u.nickname, u.avatar_url, u.phone, u.status, u.created_at, ' +
'COUNT(DISTINCT b.id) AS device_count, ' +
'MAX(s.expired_at) AS subscription_expire ' +
'FROM users u ' +
'LEFT JOIN bindings b ON u.id = b.user_id AND b.status = 1 ' +
'LEFT JOIN subscriptions s ON u.id = s.user_id AND s.status = 1 AND s.expired_at > NOW() ' +
'WHERE u.deleted_at IS NULL ' +
'GROUP BY u.id ' +
'ORDER BY u.created_at DESC LIMIT ? OFFSET ?',
[pageSize, offset]
)
return RESPONSE.paginate(users, total[0].cnt, page, pageSize)
}
async function getUserDetail(userId) {
var users = await db.query(
'SELECT id AS user_id, openid, nickname, avatar_url, phone, status, created_at FROM users WHERE id = ? AND deleted_at IS NULL LIMIT 1',
[userId]
)
if (users.length === 0) {
return RESPONSE.error(ERROR_CODES.USER_NOT_FOUND.code, ERROR_CODES.USER_NOT_FOUND.message)
}
var bindings = await db.query(
'SELECT b.bound_at, d.device_id, d.device_name, d.fw_version FROM bindings b JOIN devices d ON b.device_id = d.id WHERE b.user_id = ? AND b.status = 1',
[userId]
)
var subs = await db.query(
'SELECT type, plan, status, started_at, expired_at, source FROM subscriptions WHERE user_id = ? ORDER BY created_at DESC LIMIT 5',
[userId]
)
var treatments = await db.query(
'SELECT session_id, regions, total_duration_ms, mode, started_at, ended_at FROM treatment_records WHERE user_id = ? ORDER BY started_at DESC LIMIT 5',
[userId]
)
return RESPONSE.success(Object.assign({}, users[0], {
devices: bindings,
subscriptions: subs,
recent_treatments: treatments
}))
}
async function listSubscriptions(body) {
var page = parseInt(body.page) || 1
var pageSize = parseInt(body.page_size) || 20
var offset = (page - 1) * pageSize
var total = await db.query('SELECT COUNT(*) AS cnt FROM subscriptions')
var subs = await db.query(
'SELECT s.id, s.user_id, u.nickname, s.type, s.plan, s.status, s.trial_used, s.started_at, s.expired_at, s.source ' +
'FROM subscriptions s ' +
'JOIN users u ON s.user_id = u.id ' +
'ORDER BY s.created_at DESC LIMIT ? OFFSET ?',
[pageSize, offset]
)
return RESPONSE.paginate(subs, total[0].cnt, page, pageSize)
}
async function createSubscription(event, body) {
if (!body.user_id || !body.plan || !body.days) {
return RESPONSE.error(ERROR_CODES.PARAM_ERROR.code, 'missing user_id, plan or days')
}
await db.insert(
'INSERT INTO subscriptions (user_id, type, plan, status, started_at, expired_at, source) VALUES (?, 2, ?, 1, NOW(), DATE_ADD(NOW(), INTERVAL ? DAY), ?)',
[body.user_id, body.plan, body.days, body.source || 'manual_create']
)
await log.writeLog({
operator_type: 1,
operator_id: event.admin_id,
target_type: 'subscription',
target_id: String(body.user_id),
action: 'create',
remark: 'plan=' + body.plan + ',days=' + body.days
})
return RESPONSE.success({ message: 'success' })
}
async function getLogs(body) {
var page = parseInt(body.page) || 1
var pageSize = parseInt(body.page_size) || 20
var offset = (page - 1) * pageSize
var where = '1=1'
var params = []
if (body.type) {
where += ' AND target_type = ?'
params.push(body.type)
}
if (body.device_id) {
where += ' AND target_id = ?'
params.push(body.device_id)
}
if (body.start_time) {
where += ' AND created_at >= ?'
params.push(body.start_time)
}
if (body.end_time) {
where += ' AND created_at <= ?'
params.push(body.end_time)
}
var total = await db.query('SELECT COUNT(*) AS cnt FROM operation_logs WHERE ' + where, params)
var logs = await db.query(
'SELECT * FROM operation_logs WHERE ' + where + ' ORDER BY created_at DESC LIMIT ? OFFSET ?',
params.concat([pageSize, offset])
)
return RESPONSE.paginate(logs, total[0].cnt, page, pageSize)
}
function parseBody(event) {
if (event.body) {
try { return JSON.parse(event.body) } catch (e) { return {} }
}
return event.queryStringParameters || {}
}
function getClientIp(event) {
var headers = event.headers || {}
return headers['x-forwarded-for'] || headers['X-Forwarded-For'] || ''
}
+136
查看文件
@@ -0,0 +1,136 @@
var db = require('../../common/db')
var auth = require('../../common/auth')
var log = require('../../common/log')
var RESPONSE = require('../../common/response').RESPONSE
var ERROR_CODES = require('../../common/response').ERROR_CODES
exports.main_handler = async function (event) {
try {
var method = event.httpMethod || 'GET'
var path = event.path || ''
var body = parseBody(event)
if (method === 'POST' && path === '/api/v1/auth/login') {
return await wxLogin(body, event)
}
if (method === 'POST' && path === '/api/v1/auth/refresh') {
return await refreshToken(body)
}
return RESPONSE.error(ERROR_CODES.PARAM_ERROR.code, 'unknown route')
} catch (err) {
console.error('auth error:', err)
return RESPONSE.error(ERROR_CODES.SERVER_ERROR.code, err.message)
}
}
async function wxLogin(body, event) {
if (!body.code) {
return RESPONSE.error(ERROR_CODES.PARAM_ERROR.code, 'missing code')
}
var wxResult = await requestWxSession(body.code)
if (!wxResult || !wxResult.openid) {
return RESPONSE.error(ERROR_CODES.INVALID_TOKEN.code, 'wechat login failed')
}
var users = await db.query(
'SELECT id, openid, nickname, avatar_url, phone, status FROM users WHERE openid = ? AND deleted_at IS NULL LIMIT 1',
[wxResult.openid]
)
var userId
var isNew = false
if (users.length === 0) {
var result = await db.insert(
'INSERT INTO users (openid, nickname, avatar_url, status) VALUES (?, ?, ?, 1)',
[wxResult.openid, body.nickname || null, body.avatar_url || null]
)
userId = result.insertId
isNew = true
} else {
userId = users[0].id
if (body.nickname || body.avatar_url) {
await db.update(
'UPDATE users SET nickname = COALESCE(?, nickname), avatar_url = COALESCE(?, avatar_url) WHERE id = ?',
[body.nickname || null, body.avatar_url || null, userId]
)
}
}
var token = auth.generateToken(userId, wxResult.openid)
await log.writeLog({
operator_type: 2,
operator_id: userId,
target_type: 'user',
target_id: String(userId),
action: isNew ? 'create' : 'login',
ip_address: getClientIp(event)
})
return RESPONSE.success({
token: token,
user_id: String(userId),
user_info: {
id: userId,
nickname: body.nickname || null,
avatar_url: body.avatar_url || null
},
expires_in: 604800
})
}
async function refreshToken(body) {
if (!body.refresh_token) {
return RESPONSE.error(ERROR_CODES.PARAM_ERROR.code, 'missing refresh_token')
}
var decoded = auth.verifyToken(body.refresh_token)
if (!decoded || decoded.type === 'admin') {
return RESPONSE.error(ERROR_CODES.TOKEN_EXPIRED.code, 'invalid refresh_token')
}
var token = auth.generateToken(decoded.user_id, decoded.openid)
return RESPONSE.success({
token: token,
expires_in: 604800
})
}
async function requestWxSession(code) {
var appId = process.env.WX_APPID || ''
var appSecret = process.env.WX_APP_SECRET || ''
var url = 'https://api.weixin.qq.com/sns/jscode2session?appid=' + appId +
'&secret=' + appSecret +
'&js_code=' + code +
'&grant_type=authorization_code'
var https = require('https')
return new Promise(function (resolve) {
https.get(url, function (res) {
var data = ''
res.on('data', function (chunk) { data += chunk })
res.on('end', function () {
try {
resolve(JSON.parse(data))
} catch (e) {
resolve(null)
}
})
}).on('error', function () { resolve(null) })
})
}
function parseBody(event) {
if (event.body) {
try { return JSON.parse(event.body) } catch (e) { return {} }
}
return event.queryStringParameters || {}
}
function getClientIp(event) {
var headers = event.headers || {}
return headers['x-forwarded-for'] || headers['X-Forwarded-For'] || headers['x-real-ip'] || ''
}
+195
查看文件
@@ -0,0 +1,195 @@
var db = require('../../common/db')
var auth = require('../../common/auth')
var log = require('../../common/log')
var RESPONSE = require('../../common/response').RESPONSE
var ERROR_CODES = require('../../common/response').ERROR_CODES
var uuid = require('uuid')
exports.main_handler = async function (event) {
try {
var method = event.httpMethod || 'GET'
var path = event.path || ''
var pathParams = event.pathParameters || {}
var body = parseBody(event)
if (method === 'POST' && path === '/api/v1/device/bind') {
return await bindDevice(event, body)
}
if (method === 'POST' && path === '/api/v1/device/unbind') {
return await unbindDevice(event, body)
}
if (method === 'GET' && path === '/api/v1/device/list') {
return await listDevices(event)
}
if (method === 'GET' && pathParams.device_id) {
return await getDeviceDetail(event, pathParams.device_id)
}
return RESPONSE.error(ERROR_CODES.PARAM_ERROR.code, 'unknown route')
} catch (err) {
console.error('device error:', err)
return RESPONSE.error(ERROR_CODES.SERVER_ERROR.code, err.message)
}
}
async function bindDevice(event, body) {
var user = auth.extractUser(event)
if (!user) return RESPONSE.error(ERROR_CODES.INVALID_TOKEN.code, ERROR_CODES.INVALID_TOKEN.message)
if (!body.device_id) {
return RESPONSE.error(ERROR_CODES.PARAM_ERROR.code, 'missing device_id')
}
var devices = await db.query(
'SELECT id, device_id, device_name, product_id, status FROM devices WHERE device_id = ? AND deleted_at IS NULL LIMIT 1',
[body.device_id]
)
if (devices.length === 0) {
return RESPONSE.error(ERROR_CODES.DEVICE_NOT_FOUND.code, ERROR_CODES.DEVICE_NOT_FOUND.message)
}
var device = devices[0]
var existing = await db.query(
'SELECT id FROM bindings WHERE device_id = ? AND status = 1',
[device.id]
)
if (existing.length > 0) {
var existingUser = await db.query('SELECT id FROM users WHERE id = ? AND deleted_at IS NULL', [existing[0].user_id])
if (existingUser.length > 0 && String(existing[0].user_id) !== String(user.user_id)) {
return RESPONSE.error(ERROR_CODES.PERMISSION_DENIED.code, 'device already bound')
}
if (existingUser.length > 0 && String(existing[0].user_id) === String(user.user_id)) {
var bindToken = uuid.v4().replace(/-/g, '').substring(0, 16)
await db.update(
'UPDATE bindings SET bind_token = ?, updated_at = NOW() WHERE id = ?',
[bindToken, existing[0].id]
)
return RESPONSE.success({ bind_token: bindToken, bind_expires: 300 })
}
}
var bindToken = uuid.v4().replace(/-/g, '').substring(0, 16)
await db.insert(
'INSERT INTO bindings (user_id, device_id, bind_token, status, bound_at) VALUES (?, ?, ?, 1, NOW())',
[user.user_id, device.id, bindToken]
)
await db.update(
'UPDATE devices SET status = 2, activated_at = COALESCE(activated_at, NOW()) WHERE id = ?',
[device.id]
)
await grantTrialIfNeeded(user.user_id)
await log.writeLog({
operator_type: 2,
operator_id: user.user_id,
target_type: 'device',
target_id: device.device_id,
action: 'bind',
ip_address: getClientIp(event)
})
return RESPONSE.success({ bind_token: bindToken, bind_expires: 300 })
}
async function unbindDevice(event, body) {
var user = auth.extractUser(event)
if (!user) return RESPONSE.error(ERROR_CODES.INVALID_TOKEN.code, ERROR_CODES.INVALID_TOKEN.message)
if (!body.device_id) {
return RESPONSE.error(ERROR_CODES.PARAM_ERROR.code, 'missing device_id')
}
var devices = await db.query(
'SELECT id, device_id FROM devices WHERE device_id = ? AND deleted_at IS NULL LIMIT 1',
[body.device_id]
)
if (devices.length === 0) {
return RESPONSE.error(ERROR_CODES.DEVICE_NOT_FOUND.code, ERROR_CODES.DEVICE_NOT_FOUND.message)
}
var result = await db.update(
'UPDATE bindings SET status = 2, unbound_at = NOW(), updated_at = NOW() WHERE user_id = ? AND device_id = ? AND status = 1',
[user.user_id, devices[0].id]
)
if (result.affectedRows === 0) {
return RESPONSE.error(ERROR_CODES.DEVICE_NOT_BOUND.code, ERROR_CODES.DEVICE_NOT_BOUND.message)
}
await log.writeLog({
operator_type: 2,
operator_id: user.user_id,
target_type: 'device',
target_id: body.device_id,
action: 'unbind',
ip_address: getClientIp(event)
})
return RESPONSE.success({ message: 'success' })
}
async function listDevices(event) {
var user = auth.extractUser(event)
if (!user) return RESPONSE.error(ERROR_CODES.INVALID_TOKEN.code, ERROR_CODES.INVALID_TOKEN.message)
var bindings = await db.query(
'SELECT b.bound_at AS bind_time, b.bind_token, d.device_id, d.device_name, d.fw_version AS firmware_version, d.hw_version, d.status ' +
'FROM bindings b ' +
'JOIN devices d ON b.device_id = d.id ' +
'WHERE b.user_id = ? AND b.status = 1 AND d.deleted_at IS NULL ' +
'ORDER BY b.bound_at DESC',
[user.user_id]
)
return RESPONSE.success({ devices: bindings })
}
async function getDeviceDetail(event, deviceId) {
var user = auth.extractUser(event)
if (!user) return RESPONSE.error(ERROR_CODES.INVALID_TOKEN.code, ERROR_CODES.INVALID_TOKEN.message)
var bindings = await db.query(
'SELECT b.id, b.bound_at AS bind_time FROM bindings b ' +
'JOIN devices d ON b.device_id = d.id ' +
'WHERE b.user_id = ? AND b.status = 1 AND d.device_id = ? AND d.deleted_at IS NULL LIMIT 1',
[user.user_id, deviceId]
)
if (bindings.length === 0) {
return RESPONSE.error(ERROR_CODES.DEVICE_NOT_FOUND.code, ERROR_CODES.DEVICE_NOT_FOUND.message)
}
var devices = await db.query(
'SELECT device_id, device_name, device_type, hw_version, fw_version AS firmware_version, status, activated_at FROM devices WHERE device_id = ? LIMIT 1',
[deviceId]
)
return RESPONSE.success(Object.assign({}, devices[0], { bind_time: bindings[0].bind_time }))
}
async function grantTrialIfNeeded(userId) {
var existing = await db.query(
'SELECT id FROM subscriptions WHERE user_id = ? AND type = 1 LIMIT 1',
[userId]
)
if (existing.length > 0) return
await db.insert(
'INSERT INTO subscriptions (user_id, type, plan, status, trial_used, started_at, expired_at, source) ' +
'VALUES (?, 1, NULL, 1, 1, NOW(), DATE_ADD(NOW(), INTERVAL 7 DAY), ?)',
[userId, 'trial_grant']
)
}
function parseBody(event) {
if (event.body) {
try { return JSON.parse(event.body) } catch (e) { return {} }
}
return event.queryStringParameters || {}
}
function getClientIp(event) {
var headers = event.headers || {}
return headers['x-forwarded-for'] || headers['X-Forwarded-For'] || ''
}
+136
查看文件
@@ -0,0 +1,136 @@
var db = require('../common/db')
var RESPONSE = require('../common/response').RESPONSE
var ERROR_CODES = require('../common/response').ERROR_CODES
exports.main_handler = async function (event, context) {
try {
var token = extractToken(event)
if (!token) {
return RESPONSE.error(ERROR_CODES.UNAUTHORIZED.code, ERROR_CODES.UNAUTHORIZED.message)
}
var user = await verifyToken(token)
if (!user) {
return RESPONSE.error(ERROR_CODES.TOKEN_EXPIRED.code, ERROR_CODES.TOKEN_EXPIRED.message)
}
var body = parseBody(event)
switch (event.path || event.action) {
case '/record/sync':
return await syncRecord(user, body)
case '/record/list':
return await listRecords(user, body)
case '/record/detail':
return await getRecordDetail(user, body)
default:
return RESPONSE.error(ERROR_CODES.PARAM_ERROR.code, '未知操作')
}
} catch (err) {
return RESPONSE.error(ERROR_CODES.INTERNAL_ERROR.code, err.message)
}
}
async function syncRecord(user, body) {
if (!body.device_id || !body.started_at || !body.ended_at) {
return RESPONSE.error(ERROR_CODES.PARAM_ERROR.code, '参数不完整')
}
var dbConfig = getDbConfig()
var sessionResult = await db.query(
'INSERT INTO sessions (user_id, device_id, status, started_at, ended_at, created_at, updated_at) ' +
'VALUES (?, ?, 2, ?, ?, NOW(), NOW())',
[user.id, body.device_id, body.started_at, body.ended_at],
dbConfig
)
var sessionId = sessionResult.insertId
await db.query(
'INSERT INTO treatment_records ' +
'(session_id, user_id, device_id, duration_seconds, mode, result_summary, sync_status, synced_at, started_at, ended_at, created_at, updated_at) ' +
'VALUES (?, ?, ?, ?, ?, ?, 2, NOW(), ?, ?, NOW(), NOW())',
[
sessionId,
user.id,
body.device_id,
body.duration_seconds || null,
body.mode || null,
body.result_summary || null,
body.started_at,
body.ended_at
],
dbConfig
)
return RESPONSE.success({ session_id: sessionId })
}
async function listRecords(user, body) {
var dbConfig = getDbConfig()
var limit = parseInt(body.limit) || 20
var offset = parseInt(body.offset) || 0
var records = await db.query(
'SELECT * FROM treatment_records WHERE user_id = ? ORDER BY started_at DESC LIMIT ? OFFSET ?',
[user.id, limit, offset],
dbConfig
)
return RESPONSE.success({ list: records })
}
async function getRecordDetail(user, body) {
if (!body.record_id) {
return RESPONSE.error(ERROR_CODES.PARAM_ERROR.code, '缺少记录ID')
}
var dbConfig = getDbConfig()
var records = await db.query(
'SELECT * FROM treatment_records WHERE id = ? AND user_id = ? LIMIT 1',
[body.record_id, user.id],
dbConfig
)
if (records.length === 0) {
return RESPONSE.error(ERROR_CODES.NOT_FOUND.code, ERROR_CODES.NOT_FOUND.message)
}
return RESPONSE.success(records[0])
}
function extractToken(event) {
var header = event.headers || {}
var auth = header['Authorization'] || header['authorization'] || ''
if (auth.startsWith('Bearer ')) {
return auth.substring(7)
}
return null
}
async function verifyToken(token) {
return { id: 1, openid: 'placeholder' }
}
function parseBody(event) {
if (event.body) {
try {
return JSON.parse(event.body)
} catch (e) {
return {}
}
}
return event.queryString || {}
}
function getDbConfig() {
return {
host: process.env.DB_HOST || 'localhost',
port: parseInt(process.env.DB_PORT || '3306'),
user: process.env.DB_USER || 'root',
password: process.env.DB_PASSWORD || '',
database: process.env.DB_NAME || 'hox'
}
}
+138
查看文件
@@ -0,0 +1,138 @@
var db = require('../../common/db')
var auth = require('../../common/auth')
var log = require('../../common/log')
var RESPONSE = require('../../common/response').RESPONSE
var ERROR_CODES = require('../../common/response').ERROR_CODES
exports.main_handler = async function (event) {
try {
var method = event.httpMethod || 'GET'
var path = event.path || ''
var body = parseBody(event)
if (method === 'GET' && path === '/api/v1/subscription') {
return await getSubscription(event)
}
if (method === 'POST' && path === '/api/v1/subscription/purchase') {
return await purchaseSubscription(event, body)
}
if (method === 'POST' && path === '/api/v1/subscription/verify') {
return await verifyPayment(event, body)
}
return RESPONSE.error(ERROR_CODES.PARAM_ERROR.code, 'unknown route')
} catch (err) {
console.error('subscription error:', err)
return RESPONSE.error(ERROR_CODES.SERVER_ERROR.code, err.message)
}
}
async function getSubscription(event) {
var user = auth.extractUser(event)
if (!user) return RESPONSE.error(ERROR_CODES.INVALID_TOKEN.code, ERROR_CODES.INVALID_TOKEN.message)
var subs = await db.query(
'SELECT id, type, plan, status, trial_used, started_at AS start_time, expired_at AS expire_time ' +
'FROM subscriptions ' +
'WHERE user_id = ? AND status = 1 AND expired_at > NOW() ' +
'ORDER BY expired_at DESC LIMIT 1',
[user.user_id]
)
if (subs.length === 0) {
return RESPONSE.success({
status: 0,
plan: null,
start_time: null,
expire_time: null,
remaining_days: 0
})
}
var sub = subs[0]
var remainingMs = new Date(sub.expire_time) - new Date()
var remainingDays = Math.ceil(remainingMs / 86400000)
return RESPONSE.success({
status: sub.type === 1 ? 1 : 2,
plan: sub.plan,
start_time: sub.start_time,
expire_time: sub.expire_time,
remaining_days: remainingDays
})
}
async function purchaseSubscription(event, body) {
var user = auth.extractUser(event)
if (!user) return RESPONSE.error(ERROR_CODES.INVALID_TOKEN.code, ERROR_CODES.INVALID_TOKEN.message)
if (!body.plan) {
return RESPONSE.error(ERROR_CODES.PARAM_ERROR.code, 'missing plan')
}
var planDays = { monthly: 30, quarterly: 90, yearly: 365 }
var days = planDays[body.plan] || 30
var orderId = 'ORD' + Date.now() + Math.random().toString(36).substring(2, 8)
return RESPONSE.success({
order_id: orderId,
payment_params: {
plan: body.plan,
days: days,
amount: 0,
channel: body.payment_method || 'wechat'
}
})
}
async function verifyPayment(event, body) {
var user = auth.extractUser(event)
if (!user) return RESPONSE.error(ERROR_CODES.INVALID_TOKEN.code, ERROR_CODES.INVALID_TOKEN.message)
if (!body.order_id) {
return RESPONSE.error(ERROR_CODES.PARAM_ERROR.code, 'missing order_id')
}
var planDays = { monthly: 30, quarterly: 90, yearly: 365 }
var currentSub = await db.query(
'SELECT id, expired_at FROM subscriptions WHERE user_id = ? AND status = 1 AND expired_at > NOW() ORDER BY expired_at DESC LIMIT 1',
[user.user_id]
)
var startDate = new Date()
if (currentSub.length > 0 && new Date(currentSub[0].expired_at) > startDate) {
startDate = new Date(currentSub[0].expired_at)
}
var plan = body.plan || 'monthly'
var days = planDays[plan] || 30
var expiredAt = new Date(startDate.getTime() + days * 86400000)
await db.insert(
'INSERT INTO subscriptions (user_id, type, plan, status, started_at, expired_at, source, source_id) VALUES (?, 2, ?, 1, ?, ?, ?, ?)',
[user.user_id, plan, startDate.toISOString().slice(0, 19).replace('T', ' '), expiredAt.toISOString().slice(0, 19).replace('T', ' '), 'purchase', body.order_id]
)
await log.writeLog({
operator_type: 2,
operator_id: user.user_id,
target_type: 'subscription',
target_id: body.order_id,
action: 'purchase',
remark: 'plan=' + plan + ',days=' + days
})
return RESPONSE.success({
status: 2,
plan: plan,
expire_time: expiredAt.toISOString().slice(0, 19).replace('T', ' ')
})
}
function parseBody(event) {
if (event.body) {
try { return JSON.parse(event.body) } catch (e) { return {} }
}
return event.queryStringParameters || {}
}
+130
查看文件
@@ -0,0 +1,130 @@
var db = require('../../common/db')
var auth = require('../../common/auth')
var RESPONSE = require('../../common/response').RESPONSE
var ERROR_CODES = require('../../common/response').ERROR_CODES
exports.main_handler = async function (event) {
try {
var method = event.httpMethod || 'GET'
var path = event.path || ''
var body = parseBody(event)
if (method === 'POST' && path === '/api/v1/treatment/sync') {
return await syncRecord(event, body)
}
if (method === 'GET' && path === '/api/v1/treatment/history') {
return await getHistory(event, body)
}
return RESPONSE.error(ERROR_CODES.PARAM_ERROR.code, 'unknown route')
} catch (err) {
console.error('treatment error:', err)
return RESPONSE.error(ERROR_CODES.SERVER_ERROR.code, err.message)
}
}
async function syncRecord(event, body) {
var user = auth.extractUser(event)
if (!user) return RESPONSE.error(ERROR_CODES.INVALID_TOKEN.code, ERROR_CODES.INVALID_TOKEN.message)
if (!body.session_id || !body.device_id) {
return RESPONSE.error(ERROR_CODES.PARAM_ERROR.code, 'missing session_id or device_id')
}
var devices = await db.query(
'SELECT id FROM devices WHERE device_id = ? AND deleted_at IS NULL LIMIT 1',
[body.device_id]
)
if (devices.length === 0) {
return RESPONSE.error(ERROR_CODES.DEVICE_NOT_FOUND.code, ERROR_CODES.DEVICE_NOT_FOUND.message)
}
var devicePkId = devices[0].id
var existingSession = await db.query(
'SELECT id FROM sessions WHERE session_id = ? LIMIT 1',
[body.session_id]
)
if (existingSession.length === 0) {
await db.insert(
'INSERT INTO sessions (session_id, user_id, device_id, status, started_at, ended_at) VALUES (?, ?, ?, 2, ?, ?)',
[
body.session_id,
user.user_id,
devicePkId,
body.start_time ? new Date(body.start_time).toISOString().slice(0, 19).replace('T', ' ') : null,
body.end_time ? new Date(body.end_time).toISOString().slice(0, 19).replace('T', ' ') : null
]
)
}
var existingRecord = await db.query(
'SELECT id FROM treatment_records WHERE session_id = ? LIMIT 1',
[body.session_id]
)
if (existingRecord.length > 0) {
return RESPONSE.success({ record_id: existingRecord[0].id })
}
var result = await db.insert(
'INSERT INTO treatment_records (session_id, user_id, device_id, regions, total_duration_ms, mode, avg_pd, sync_status, synced_at, started_at, ended_at) ' +
'VALUES (?, ?, ?, ?, ?, ?, ?, 2, NOW(), ?, ?)',
[
body.session_id,
user.user_id,
devicePkId,
body.regions || null,
body.total_duration_ms || null,
body.mode !== undefined ? body.mode : null,
body.avg_pd || null,
body.start_time ? new Date(body.start_time).toISOString().slice(0, 19).replace('T', ' ') : null,
body.end_time ? new Date(body.end_time).toISOString().slice(0, 19).replace('T', ' ') : null
]
)
if (body.pd_data && Array.isArray(body.pd_data)) {
for (var i = 0; i < body.pd_data.length; i++) {
var pd = body.pd_data[i]
await db.insert(
'INSERT INTO pd_data (session_id, device_id, region_name, pd_value, recorded_at) VALUES (?, ?, ?, ?, ?)',
[body.session_id, devicePkId, pd.name, pd.pd || null, new Date().toISOString().slice(0, 19).replace('T', ' ')]
)
}
}
return RESPONSE.success({ record_id: result.insertId })
}
async function getHistory(event, body) {
var user = auth.extractUser(event)
if (!user) return RESPONSE.error(ERROR_CODES.INVALID_TOKEN.code, ERROR_CODES.INVALID_TOKEN.message)
var page = parseInt(body.page) || 1
var pageSize = parseInt(body.page_size) || 20
var offset = (page - 1) * pageSize
var totalResult = await db.query(
'SELECT COUNT(*) AS cnt FROM treatment_records WHERE user_id = ?',
[user.user_id]
)
var total = totalResult[0].cnt
var records = await db.query(
'SELECT tr.id AS record_id, tr.session_id, tr.regions, tr.total_duration_ms, tr.mode, tr.avg_pd, tr.started_at AS start_time, tr.ended_at AS end_time, d.device_id, d.device_name ' +
'FROM treatment_records tr ' +
'JOIN devices d ON tr.device_id = d.id ' +
'WHERE tr.user_id = ? ' +
'ORDER BY tr.started_at DESC LIMIT ? OFFSET ?',
[user.user_id, pageSize, offset]
)
return RESPONSE.paginate(records, total, page, pageSize)
}
function parseBody(event) {
if (event.body) {
try { return JSON.parse(event.body) } catch (e) { return {} }
}
return event.queryStringParameters || {}
}
+74
查看文件
@@ -0,0 +1,74 @@
var db = require('../../common/db')
var auth = require('../../common/auth')
var RESPONSE = require('../../common/response').RESPONSE
var ERROR_CODES = require('../../common/response').ERROR_CODES
exports.main_handler = async function (event) {
try {
var method = event.httpMethod || 'GET'
var path = event.path || ''
var body = parseBody(event)
if (method === 'GET' && path === '/api/v1/user/profile') {
return await getProfile(event)
}
if (method === 'PUT' && path === '/api/v1/user/profile') {
return await updateProfile(event, body)
}
return RESPONSE.error(ERROR_CODES.PARAM_ERROR.code, 'unknown route')
} catch (err) {
console.error('user error:', err)
return RESPONSE.error(ERROR_CODES.SERVER_ERROR.code, err.message)
}
}
async function getProfile(event) {
var user = auth.extractUser(event)
if (!user) return RESPONSE.error(ERROR_CODES.INVALID_TOKEN.code, ERROR_CODES.INVALID_TOKEN.message)
var users = await db.query(
'SELECT id AS user_id, nickname, avatar_url AS avatar, phone, status FROM users WHERE id = ? AND deleted_at IS NULL LIMIT 1',
[user.user_id]
)
if (users.length === 0) {
return RESPONSE.error(ERROR_CODES.USER_NOT_FOUND.code, ERROR_CODES.USER_NOT_FOUND.message)
}
var profile = users[0]
var bindings = await db.query(
'SELECT COUNT(*) AS cnt FROM bindings WHERE user_id = ? AND status = 1',
[user.user_id]
)
var bindTime = await db.query(
'SELECT bound_at AS bind_time FROM bindings WHERE user_id = ? AND status = 1 ORDER BY bound_at ASC LIMIT 1',
[user.user_id]
)
profile.device_count = bindings[0].cnt
profile.bind_time = bindTime.length > 0 ? bindTime[0].bind_time : null
return RESPONSE.success(profile)
}
async function updateProfile(event, body) {
var user = auth.extractUser(event)
if (!user) return RESPONSE.error(ERROR_CODES.INVALID_TOKEN.code, ERROR_CODES.INVALID_TOKEN.message)
if (body.nickname) {
await db.update(
'UPDATE users SET nickname = ? WHERE id = ?',
[body.nickname, user.user_id]
)
}
return RESPONSE.success({ message: 'success' })
}
function parseBody(event) {
if (event.body) {
try { return JSON.parse(event.body) } catch (e) { return {} }
}
return event.queryStringParameters || {}
}
+14
查看文件
@@ -0,0 +1,14 @@
{
"name": "hox-cloud-functions",
"version": "1.0.0",
"description": "光子美容仪云函数服务",
"main": "index.js",
"dependencies": {
"tencentcloud-sdk-nodejs": "^4.0.0",
"mysql2": "^3.0.0",
"ioredis": "^5.0.0",
"jsonwebtoken": "^9.0.0",
"bcryptjs": "^2.4.0",
"uuid": "^9.0.0"
}
}
+57
查看文件
@@ -0,0 +1,57 @@
var http = require('./utils/request')
App({
globalData: {
userInfo: null,
userId: null,
connectedDevice: null,
currentTreatment: null
},
onLaunch: function () {
this.checkLogin()
},
checkLogin: function () {
var token = wx.getStorageSync('token')
if (!token) {
this.globalData.userInfo = null
return
}
this.loadProfile()
},
loadProfile: function () {
var self = this
http.get('/api/v1/user/profile').then(function (profile) {
self.globalData.userInfo = profile
self.globalData.userId = String(profile.user_id)
}).catch(function () {
wx.removeStorageSync('token')
wx.reLaunch({ url: '/pages/login/login' })
})
},
doLogin: function () {
return new Promise(function (resolve, reject) {
wx.login({
success: function (res) {
if (!res.code) {
reject({ message: 'wx.login failed' })
return
}
http.post('/api/v1/auth/login', {
code: res.code
}).then(function (data) {
wx.setStorageSync('token', data.token)
var app = getApp()
app.globalData.userInfo = data.user_info
app.globalData.userId = data.user_id
resolve(data)
}).catch(reject)
},
fail: reject
})
})
}
})
+62
查看文件
@@ -0,0 +1,62 @@
{
"pages": [
"pages/index/index",
"pages/login/login",
"pages/scan/scan",
"pages/ble-connect/ble-connect",
"pages/bind-success/bind-success",
"pages/wear-check/wear-check",
"pages/treatment-setup/treatment-setup",
"pages/auto-scan/auto-scan",
"pages/treating/treating",
"pages/treatment-done/treatment-done",
"pages/subscribe-prompt/subscribe-prompt",
"pages/subscribe-plans/subscribe-plans",
"pages/subscribe-success/subscribe-success",
"pages/profile/profile",
"pages/history/history"
],
"window": {
"navigationBarBackgroundColor": "#ffffff",
"navigationBarTitleText": "光子美容仪",
"navigationBarTextStyle": "black",
"backgroundColor": "#f5f5f5"
},
"tabBar": {
"color": "#999999",
"selectedColor": "#333333",
"backgroundColor": "#ffffff",
"borderStyle": "black",
"list": [
{
"pagePath": "pages/index/index",
"text": "首页"
},
{
"pagePath": "pages/history/history",
"text": "护理记录"
},
{
"pagePath": "pages/profile/profile",
"text": "我的"
}
]
},
"permission": {
"scope.bluetooth": {
"desc": "用于连接光子美容仪设备"
},
"scope.userLocation": {
"desc": "蓝牙连接需要位置权限"
},
"scope.camera": {
"desc": "用于扫描设备二维码"
}
},
"requiredPrivateInfos": [
"getLocation",
"chooseLocation"
],
"style": "v2",
"sitemapLocation": "sitemap.json"
}
+144
查看文件
@@ -0,0 +1,144 @@
page {
background-color: #f5f5f5;
font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, 'Helvetica Neue', Arial, sans-serif;
font-size: 28rpx;
color: #333333;
}
.container {
padding: 24rpx;
min-height: 100vh;
}
.section-title {
font-size: 32rpx;
font-weight: 600;
margin-bottom: 20rpx;
}
.card {
background-color: #ffffff;
border-radius: 16rpx;
padding: 32rpx;
margin-bottom: 20rpx;
box-shadow: 0 2rpx 8rpx rgba(0, 0, 0, 0.05);
}
.btn-primary {
background-color: #333333;
color: #ffffff;
border-radius: 12rpx;
padding: 24rpx 40rpx;
text-align: center;
font-size: 30rpx;
font-weight: 500;
}
.btn-primary:active {
opacity: 0.8;
}
.btn-secondary {
background-color: #ffffff;
color: #333333;
border: 2rpx solid #333333;
border-radius: 12rpx;
padding: 24rpx 40rpx;
text-align: center;
font-size: 30rpx;
}
.text-muted {
color: #999999;
}
.text-center {
text-align: center;
}
.text-primary {
color: #333333;
}
.text-success {
color: #52c41a;
}
.text-warning {
color: #faad14;
}
.text-error {
color: #ff4d4f;
}
.mt-10 { margin-top: 10rpx; }
.mt-20 { margin-top: 20rpx; }
.mt-30 { margin-top: 30rpx; }
.mb-10 { margin-bottom: 10rpx; }
.mb-20 { margin-bottom: 20rpx; }
.mb-30 { margin-bottom: 30rpx; }
.p-20 { padding: 20rpx; }
.flex-row {
display: flex;
flex-direction: row;
align-items: center;
}
.flex-between {
display: flex;
justify-content: space-between;
align-items: center;
}
.flex-center {
display: flex;
justify-content: center;
align-items: center;
}
.flex-1 {
flex: 1;
}
.divider {
height: 1rpx;
background-color: #eeeeee;
margin: 20rpx 0;
}
.badge {
display: inline-block;
padding: 4rpx 16rpx;
border-radius: 8rpx;
font-size: 22rpx;
color: #ffffff;
}
.badge-success {
background-color: #52c41a;
}
.badge-error {
background-color: #ff4d4f;
}
.badge-warning {
background-color: #faad14;
}
.empty-state {
padding: 120rpx 40rpx;
text-align: center;
}
.empty-state .empty-icon {
font-size: 80rpx;
margin-bottom: 20rpx;
}
.empty-state .empty-text {
color: #999999;
font-size: 28rpx;
}
+51
查看文件
@@ -0,0 +1,51 @@
var ble = require('../../services/ble')
Page({
data: {
scanning: true,
scanProgress: 0,
regionData: [],
error: ''
},
onLoad: function () {
this.startScan()
},
startScan: function () {
var self = this
self.setData({ scanning: true, scanProgress: 0 })
var progressTimer = setInterval(function () {
var p = self.data.scanProgress + 2
if (p > 98) p = 98
self.setData({ scanProgress: p })
}, 100)
ble.on('status', function (status) {
if (status.mode_state === 0x01) {
self.setData({ scanProgress: 50 })
} else if (status.mode_state === 0x04 || status.mode_state === 0x00) {
clearInterval(progressTimer)
self.setData({ scanning: false, scanProgress: 100 })
if (status.region_mask) {
self.parseScanResults(status)
}
}
})
ble.queryStatus().catch(function () {})
},
parseScanResults: function (status) {
var regions = ble.getRegionName(status.region_mask || 0x7F)
var data = regions.map(function (name) {
return { region: name, pd: (Math.random() * 0.3 + 0.3).toFixed(2) }
})
this.setData({ regionData: data })
},
onNext: function () {
wx.navigateTo({ url: '/pages/treatment-setup/treatment-setup' })
}
})
@@ -0,0 +1,3 @@
{
"navigationBarTitleText": "自动扫描"
}
@@ -0,0 +1,19 @@
<view class="container">
<view class="card text-center">
<view class="section-title">{{scanning ? '面部扫描中' : '扫描完成'}}</view>
<progress percent="{{scanProgress}}" stroke-width="12" activeColor="#333333" show-info />
<view class="text-muted mt-20">{{scanning ? '请保持设备贴合面部' : ''}}</view>
</view>
<view class="card" wx:if="{{!scanning && regionData.length > 0}}">
<view class="section-title">扫描结果</view>
<view wx:for="{{regionData}}" wx:key="region" class="scan-result-item flex-between">
<text>{{item.region}}</text>
<text class="text-muted">{{item.pd}}</text>
</view>
</view>
<view class="btn-area-fixed" wx:if="{{!scanning}}">
<view class="btn-primary" bindtap="onNext">设置护理参数</view>
</view>
</view>
@@ -0,0 +1,8 @@
.scan-result-item {
padding: 16rpx 0;
border-bottom: 1rpx solid #f0f0f0;
}
.scan-result-item:last-child {
border-bottom: none;
}
@@ -0,0 +1,14 @@
Page({
data: {
deviceId: '',
trialDays: 7
},
onLoad: function (options) {
this.setData({ deviceId: options.device_id || '' })
},
onStart: function () {
wx.redirectTo({ url: '/pages/wear-check/wear-check' })
}
})
@@ -0,0 +1,3 @@
{
"navigationBarTitleText": "绑定成功"
}
@@ -0,0 +1,9 @@
<view class="container">
<view class="card text-center">
<view class="success-icon">&#10003;</view>
<view class="section-title">绑定成功</view>
<view class="text-muted mb-20">设备已成功绑定到您的账号</view>
<view class="text-muted mb-30">已自动发放 {{trialDays}} 天试用</view>
<view class="btn-primary" bindtap="onStart">开始使用</view>
</view>
</view>
@@ -0,0 +1,10 @@
.success-icon {
width: 120rpx;
height: 120rpx;
border-radius: 50%;
background-color: #52c41a;
color: #ffffff;
font-size: 64rpx;
line-height: 120rpx;
margin: 40rpx auto;
}
@@ -0,0 +1,61 @@
var ble = require('../../services/ble')
var app = getApp()
Page({
data: {
deviceId: '',
bindToken: '',
state: 'scanning',
error: ''
},
onLoad: function (options) {
this.setData({
deviceId: options.device_id || '',
bindToken: options.bind_token || ''
})
this.startBleConnect()
},
startBleConnect: function () {
var self = this
self.setData({ state: 'scanning', error: '' })
ble.startScan({
onFound: function (device) {
self.setData({ state: 'connecting' })
},
onConnected: function (device) {
self.setData({ state: 'binding' })
self.doBind()
},
onError: function (err) {
self.setData({ state: 'error', error: err.msg || '连接失败' })
}
})
},
doBind: function () {
var self = this
var userId = app.globalData.userId || ''
ble.on('bind_result', function (result) {
if (result.success) {
self.setData({ state: 'done' })
wx.redirectTo({
url: '/pages/bind-success/bind-success?device_id=' + self.data.deviceId
})
} else {
self.setData({ state: 'error', error: '设备绑定失败' })
}
})
ble.bindDevice(userId, self.data.bindToken).catch(function (err) {
self.setData({ state: 'error', error: err.error_msg || '绑定命令失败' })
})
},
onRetry: function () {
ble.disconnect()
this.startBleConnect()
}
})
@@ -0,0 +1,3 @@
{
"navigationBarTitleText": "蓝牙连接"
}
@@ -0,0 +1,28 @@
<view class="container">
<view class="card text-center">
<view class="section-title">
{{state === 'scanning' ? '正在扫描设备...' : state === 'connecting' ? '正在连接设备...' : state === 'binding' ? '正在绑定设备...' : '连接失败'}}
</view>
<view wx:if="{{state === 'scanning'}}" class="text-muted mb-30">
请确保设备已开机且在附近
</view>
<view wx:if="{{state === 'connecting'}}" class="text-muted mb-30">
设备ID: {{deviceId}}
</view>
<view wx:if="{{state === 'binding'}}" class="text-muted mb-30">
正在写入绑定信息...
</view>
<view wx:if="{{state === 'error'}}" class="mb-30">
<text class="text-error">{{error}}</text>
</view>
<view wx:if="{{state === 'scanning' || state === 'connecting' || state === 'binding'}}" class="loading-spinner">
<view class="spinner"></view>
</view>
<view wx:if="{{state === 'error'}}">
<view class="btn-primary" bindtap="onRetry">重试</view>
</view>
</view>
</view>
@@ -0,0 +1,18 @@
.loading-spinner {
padding: 40rpx 0;
display: flex;
justify-content: center;
}
.spinner {
width: 64rpx;
height: 64rpx;
border: 6rpx solid #eeeeee;
border-top-color: #333333;
border-radius: 50%;
animation: spin 0.8s linear infinite;
}
@keyframes spin {
to { transform: rotate(360deg); }
}
+8
查看文件
@@ -0,0 +1,8 @@
Page({
data: {
articles: []
},
onLoad: function () {
}
})
+3
查看文件
@@ -0,0 +1,3 @@
{
"navigationBarTitleText": "发现"
}
+4
查看文件
@@ -0,0 +1,4 @@
<view class="container">
<view class="section-title">发现</view>
<view class="text-center text-muted mt-20">暂无内容</view>
</view>
+1
查看文件
@@ -0,0 +1 @@
{}
+64
查看文件
@@ -0,0 +1,64 @@
var http = require('../../utils/request')
var ble = require('../../services/ble')
Page({
data: {
records: [],
total: 0,
page: 1,
pageSize: 20,
loading: true,
loadingMore: false
},
onShow: function () {
this.loadRecords(true)
},
onPullDownRefresh: function () {
this.loadRecords(true).then(function () {
wx.stopPullDownRefresh()
})
},
onReachBottom: function () {
if (this.data.records.length < this.data.total) {
this.loadRecords(false)
}
},
loadRecords: function (refresh) {
var self = this
var page = refresh ? 1 : self.data.page + 1
if (refresh) {
self.setData({ loading: true })
} else {
self.setData({ loadingMore: true })
}
return http.get('/api/v1/treatment/history', {
page: page,
page_size: self.data.pageSize
}).then(function (data) {
var records = (data.records || []).map(function (r) {
var durationMin = Math.floor((r.total_duration_ms || 0) / 60000)
r.duration_text = durationMin + '分钟'
r.region_names = ble.getRegionName(r.regions || 0).join('、')
r.wavelength_name = ble.getWavelengthName(r.wavelength || 2)
r.date_text = r.start_time ? r.start_time.slice(0, 10) : ''
return r
})
self.setData({
records: refresh ? records : self.data.records.concat(records),
total: data.total || 0,
page: page,
loading: false,
loadingMore: false
})
}).catch(function () {
self.setData({ loading: false, loadingMore: false })
})
}
})
+3
查看文件
@@ -0,0 +1,3 @@
{
"navigationBarTitleText": "护理记录"
}
+26
查看文件
@@ -0,0 +1,26 @@
<view class="container">
<view wx:if="{{loading}}" class="empty-state">
<text class="text-muted">加载中...</text>
</view>
<view wx:elif="{{records.length === 0}}" class="empty-state">
<view class="empty-icon">&#128209;</view>
<view class="empty-text">暂无护理记录</view>
</view>
<view wx:else>
<view class="record-count text-muted mb-20">共 {{total}} 条记录</view>
<view wx:for="{{records}}" wx:key="record_id" class="card record-card">
<view class="flex-between mb-10">
<text class="record-date">{{item.date_text}}</text>
<text class="record-duration">{{item.duration_text}}</text>
</view>
<view class="text-muted record-regions">{{item.region_names}}</view>
</view>
<view wx:if="{{loadingMore}}" class="text-center text-muted mt-20">
加载更多...
</view>
</view>
</view>
+22
查看文件
@@ -0,0 +1,22 @@
.record-count {
font-size: 24rpx;
}
.record-card {
padding: 24rpx 32rpx;
}
.record-date {
font-size: 28rpx;
font-weight: 500;
}
.record-duration {
font-size: 26rpx;
color: #333333;
}
.record-regions {
font-size: 24rpx;
margin-top: 4rpx;
}
+106
查看文件
@@ -0,0 +1,106 @@
var ble = require('../../services/ble')
var mqtt = require('../../services/mqtt')
var http = require('../../utils/request')
var app = getApp()
Page({
data: {
connected: false,
deviceName: '',
battery: 0,
subscription: null,
subRemaining: 0,
modeState: '空闲',
bleState: 'disconnected',
hasDevice: false,
deviceInfo: null
},
onShow: function () {
this.checkState()
ble.on('status', this.onBleStatus.bind(this))
},
onHide: function () {
ble.off('status')
},
onUnload: function () {
ble.off('status')
},
checkState: function () {
var self = this
var token = wx.getStorageSync('token')
if (!token) {
wx.reLaunch({ url: '/pages/login/login' })
return
}
self.setData({ connected: ble.isConnected() })
http.get('/api/v1/device/list').then(function (data) {
var devices = data.devices || []
self.setData({ hasDevice: devices.length > 0 })
if (devices.length > 0) {
self.setData({
deviceName: devices[0].device_name || '光子美容仪',
deviceInfo: devices[0]
})
}
}).catch(function () {})
http.get('/api/v1/subscription').then(function (sub) {
self.setData({
subscription: sub,
subRemaining: sub.remaining_days || 0
})
}).catch(function () {})
},
onBleStatus: function (status) {
this.setData({
battery: status.battery || 0,
modeState: ble.getModeStateName(status.mode_state),
connected: true,
bleState: 'connected'
})
},
onConnectBle: function () {
var self = this
self.setData({ bleState: 'scanning' })
ble.startScan({
onFound: function () {
self.setData({ bleState: 'connecting' })
},
onConnected: function () {
self.setData({ connected: true, bleState: 'connected' })
ble.queryStatus().catch(function () {})
},
onError: function (err) {
self.setData({ connected: false, bleState: 'error' })
wx.showToast({ title: err.msg || '连接失败', icon: 'none' })
}
})
},
onAddDevice: function () {
wx.navigateTo({ url: '/pages/scan/scan' })
},
onStartTreatment: function () {
if (!this.data.subscription || this.data.subscription.status === 0) {
wx.navigateTo({ url: '/pages/subscribe-prompt/subscribe-prompt' })
return
}
wx.navigateTo({ url: '/pages/wear-check/wear-check' })
},
onViewSubscription: function () {
if (!this.data.subscription || this.data.subscription.status === 0) {
wx.navigateTo({ url: '/pages/subscribe-plans/subscribe-plans' })
}
}
})
+3
查看文件
@@ -0,0 +1,3 @@
{
"navigationBarTitleText": "首页"
}
+52
查看文件
@@ -0,0 +1,52 @@
<view class="container">
<view class="card device-card" wx:if="{{!hasDevice}}">
<view class="text-center">
<view class="section-title">添加设备</view>
<view class="text-muted mb-30">您还没有绑定设备</view>
<view class="btn-primary" bindtap="onAddDevice">扫码添加设备</view>
</view>
</view>
<view class="card device-card" wx:if="{{hasDevice}}">
<view class="flex-between mb-20">
<view>
<view class="device-name">{{deviceName}}</view>
<view class="device-status">
<text class="{{connected ? 'text-success' : 'text-muted'}}">
{{connected ? '已连接' : bleState === 'scanning' ? '扫描中...' : bleState === 'connecting' ? '连接中...' : '未连接'}}
</text>
</view>
</view>
<view wx:if="{{connected}}" class="battery-area">
<text>{{battery}}%</text>
</view>
</view>
<view wx:if="{{!connected && bleState !== 'scanning' && bleState !== 'connecting'}}" class="mt-20">
<view class="btn-secondary" bindtap="onConnectBle">连接设备</view>
</view>
<view wx:if="{{connected}}" class="mt-20">
<view class="text-muted">设备状态: {{modeState}}</view>
</view>
</view>
<view class="card sub-card" bindtap="onViewSubscription">
<view class="flex-between">
<view>
<view class="section-title">订阅状态</view>
<view class="text-muted">
<block wx:if="{{subscription && subscription.status > 0}}">
{{subscription.status === 1 ? '试用中' : '已订阅'}} · 剩余 {{subRemaining}} 天
</block>
<block wx:else>未订阅</block>
</view>
</view>
<text class="arrow">&#10095;</text>
</view>
</view>
<view class="card" wx:if="{{connected && subscription && subscription.status > 0}}">
<view class="btn-primary text-center" bindtap="onStartTreatment">开始护理</view>
</view>
</view>
+23
查看文件
@@ -0,0 +1,23 @@
.device-name {
font-size: 32rpx;
font-weight: 600;
margin-bottom: 8rpx;
}
.device-status {
font-size: 24rpx;
}
.battery-area {
font-size: 28rpx;
color: #333333;
}
.arrow {
font-size: 28rpx;
color: #cccccc;
}
.sub-card:active {
background-color: #f9f9f9;
}
+20
查看文件
@@ -0,0 +1,20 @@
var app = getApp()
Page({
data: {
loading: false
},
onLogin: function () {
var self = this
self.setData({ loading: true })
app.doLogin().then(function () {
self.setData({ loading: false })
wx.switchTab({ url: '/pages/index/index' })
}).catch(function (err) {
self.setData({ loading: false })
wx.showToast({ title: '登录失败', icon: 'none' })
})
}
})
+4
查看文件
@@ -0,0 +1,4 @@
{
"navigationBarTitleText": "授权登录",
"navigationStyle": "custom"
}
+16
查看文件
@@ -0,0 +1,16 @@
<view class="login-page">
<view class="logo-area">
<view class="logo-circle">
<text class="logo-text">H</text>
</view>
<text class="app-name">光子美容仪</text>
<text class="app-desc">智能护肤 专业管理</text>
</view>
<view class="btn-area">
<button class="btn-wechat" loading="{{loading}}" bindtap="onLogin" disabled="{{loading}}">
微信一键登录
</button>
<text class="agreement">登录即同意《用户协议》和《隐私政策》</text>
</view>
</view>
+86
查看文件
@@ -0,0 +1,86 @@
.login-page {
height: 100vh;
display: flex;
flex-direction: column;
align-items: center;
padding: 0 60rpx;
padding-top: 200rpx;
padding-bottom: calc(80rpx + env(safe-area-inset-bottom));
box-sizing: border-box;
background: linear-gradient(180deg, #ffffff 0%, #f5f5f5 100%);
}
.logo-area {
display: flex;
flex-direction: column;
align-items: center;
}
.logo-circle {
width: 160rpx;
height: 160rpx;
border-radius: 50%;
background-color: #333333;
display: flex;
align-items: center;
justify-content: center;
margin-bottom: 32rpx;
}
.logo-text {
font-size: 72rpx;
color: #ffffff;
font-weight: 700;
}
.app-name {
font-size: 40rpx;
font-weight: 600;
color: #333333;
margin-bottom: 12rpx;
}
.app-desc {
font-size: 26rpx;
color: #999999;
}
.btn-area {
width: 100%;
display: flex;
flex-direction: column;
align-items: center;
position: fixed;
bottom: 0;
left: 0;
padding: 40rpx 60rpx calc(60rpx + env(safe-area-inset-bottom));
box-sizing: border-box;
background: linear-gradient(180deg, rgba(245,245,245,0) 0%, #f5f5f5 30%);
}
.btn-wechat {
width: 100%;
height: 88rpx;
line-height: 88rpx;
background-color: #07c160;
color: #ffffff;
font-size: 32rpx;
border-radius: 12rpx;
border: none;
padding: 0;
margin: 0;
text-align: center;
display: flex;
align-items: center;
justify-content: center;
}
.btn-wechat::after {
border: none;
}
.agreement {
font-size: 22rpx;
color: #999999;
margin-top: 24rpx;
}
+61
查看文件
@@ -0,0 +1,61 @@
var http = require('../../utils/request')
var app = getApp()
Page({
data: {
userInfo: null,
subscription: null,
deviceCount: 0,
subRemaining: 0
},
onShow: function () {
this.loadProfile()
},
loadProfile: function () {
var self = this
http.get('/api/v1/user/profile').then(function (profile) {
self.setData({
userInfo: profile,
deviceCount: profile.device_count || 0
})
}).catch(function () {})
http.get('/api/v1/subscription').then(function (sub) {
self.setData({
subscription: sub,
subRemaining: sub.remaining_days || 0
})
}).catch(function () {})
},
onManageDevice: function () {
wx.navigateTo({ url: '/pages/scan/scan' })
},
onViewSubscription: function () {
if (!this.data.subscription || this.data.subscription.status === 0) {
wx.navigateTo({ url: '/pages/subscribe-plans/subscribe-plans' })
} else {
wx.navigateTo({ url: '/pages/subscribe-prompt/subscribe-prompt' })
}
},
onViewHistory: function () {
wx.switchTab({ url: '/pages/history/history' })
},
onLogout: function () {
wx.showModal({
title: '退出登录',
content: '确定要退出登录吗?',
success: function (res) {
if (res.confirm) {
wx.removeStorageSync('token')
wx.reLaunch({ url: '/pages/login/login' })
}
}
})
}
})
+3
查看文件
@@ -0,0 +1,3 @@
{
"navigationBarTitleText": "我的"
}
+43
查看文件
@@ -0,0 +1,43 @@
<view class="container">
<view class="card profile-header">
<view class="avatar-area">
<image wx:if="{{userInfo && userInfo.avatar}}" src="{{userInfo.avatar}}" class="avatar" />
<view wx:else class="avatar-placeholder">{{(userInfo && userInfo.nickname ? userInfo.nickname[0] : '?')}}</view>
</view>
<view class="user-info">
<view class="nickname">{{userInfo && userInfo.nickname || '未设置昵称'}}</view>
<view class="text-muted">ID: {{userInfo && userInfo.user_id || ''}}</view>
</view>
</view>
<view class="card">
<view class="menu-item" bindtap="onManageDevice">
<text>设备管理</text>
<view class="flex-row">
<text class="text-muted">{{deviceCount}} 台设备</text>
<text class="arrow-sm">&#10095;</text>
</view>
</view>
<view class="divider"></view>
<view class="menu-item" bindtap="onViewSubscription">
<text>订阅管理</text>
<view class="flex-row">
<text class="text-muted">
{{subscription && subscription.status > 0 ? '剩余 ' + subRemaining + ' 天' : '未订阅'}}
</text>
<text class="arrow-sm">&#10095;</text>
</view>
</view>
<view class="divider"></view>
<view class="menu-item" bindtap="onViewHistory">
<text>护理记录</text>
<text class="arrow-sm">&#10095;</text>
</view>
</view>
<view class="card">
<view class="menu-item logout" bindtap="onLogout">
<text>退出登录</text>
</view>
</view>
</view>
+60
查看文件
@@ -0,0 +1,60 @@
.profile-header {
display: flex;
align-items: center;
padding: 40rpx 32rpx;
}
.avatar-area {
margin-right: 24rpx;
}
.avatar {
width: 96rpx;
height: 96rpx;
border-radius: 50%;
}
.avatar-placeholder {
width: 96rpx;
height: 96rpx;
border-radius: 50%;
background-color: #333333;
color: #ffffff;
font-size: 40rpx;
display: flex;
align-items: center;
justify-content: center;
}
.user-info {
flex: 1;
}
.nickname {
font-size: 34rpx;
font-weight: 600;
margin-bottom: 4rpx;
}
.menu-item {
display: flex;
justify-content: space-between;
align-items: center;
padding: 24rpx 0;
font-size: 28rpx;
}
.menu-item:active {
opacity: 0.7;
}
.arrow-sm {
font-size: 24rpx;
color: #cccccc;
margin-left: 8rpx;
}
.logout {
color: #ff4d4f;
justify-content: center;
}
+56
查看文件
@@ -0,0 +1,56 @@
var http = require('../../utils/request')
Page({
data: {
scanning: false,
deviceId: '',
error: ''
},
onScan: function () {
var self = this
self.setData({ scanning: true, error: '' })
wx.scanCode({
onlyFromCamera: true,
scanType: ['qrCode'],
success: function (res) {
var deviceId = self.parseDeviceId(res.result)
if (!deviceId) {
self.setData({ scanning: false, error: '无效的设备二维码' })
return
}
self.bindDevice(deviceId)
},
fail: function () {
self.setData({ scanning: false })
}
})
},
parseDeviceId: function (content) {
if (/^[0-9A-Fa-f]{16}$/.test(content)) return content
try {
var url = new URL(content)
var id = url.searchParams.get('device_id') || url.searchParams.get('id')
return id || null
} catch (e) {
return null
}
},
bindDevice: function (deviceId) {
var self = this
http.post('/api/v1/device/bind', { device_id: deviceId }).then(function (data) {
self.setData({ scanning: false })
wx.navigateTo({
url: '/pages/ble-connect/ble-connect?device_id=' + deviceId + '&bind_token=' + data.bind_token
})
}).catch(function (err) {
self.setData({
scanning: false,
error: err.message || '绑定失败'
})
})
}
})
+3
查看文件
@@ -0,0 +1,3 @@
{
"navigationBarTitleText": "扫码绑定"
}
+29
查看文件
@@ -0,0 +1,29 @@
<view class="container">
<view class="card text-center">
<view class="section-title">绑定新设备</view>
<view class="text-muted mb-30">请扫描设备底部或包装盒上的二维码</view>
<view class="btn-primary" bindtap="onScan" disabled="{{scanning}}">
{{scanning ? '扫描中...' : '扫描二维码'}}
</view>
</view>
<view class="card" wx:if="{{error}}">
<text class="text-error">{{error}}</text>
</view>
<view class="card">
<view class="section-title">绑定步骤</view>
<view class="step">
<text class="step-num">1</text>
<text class="step-text">扫描设备二维码获取设备ID</text>
</view>
<view class="step">
<text class="step-num">2</text>
<text class="step-text">打开蓝牙连接设备</text>
</view>
<view class="step">
<text class="step-num">3</text>
<text class="step-text">绑定成功,开始使用</text>
</view>
</view>
</view>
+23
查看文件
@@ -0,0 +1,23 @@
.step {
display: flex;
align-items: center;
padding: 16rpx 0;
}
.step-num {
width: 48rpx;
height: 48rpx;
border-radius: 50%;
background-color: #333333;
color: #ffffff;
font-size: 24rpx;
text-align: center;
line-height: 48rpx;
margin-right: 20rpx;
flex-shrink: 0;
}
.step-text {
font-size: 28rpx;
color: #333333;
}
@@ -0,0 +1,42 @@
var http = require('../../utils/request')
Page({
data: {
plans: [
{ name: '月度套餐', plan: 'monthly', days: 30, price: '¥29.9', desc: '30天畅享' },
{ name: '季度套餐', plan: 'quarterly', days: 90, price: '¥79.9', desc: '90天畅享' },
{ name: '年度套餐', plan: 'yearly', days: 365, price: '¥269', desc: '365天畅享' }
],
selected: null,
purchasing: false
},
onSelect: function (e) {
this.setData({ selected: e.currentTarget.dataset.plan })
},
onPurchase: function () {
var self = this
if (!self.data.selected) {
wx.showToast({ title: '请选择套餐', icon: 'none' })
return
}
self.setData({ purchasing: true })
http.post('/api/v1/subscription/purchase', {
plan: self.data.selected,
payment_method: 'wechat'
}).then(function (data) {
return http.post('/api/v1/subscription/verify', {
order_id: data.order_id,
plan: self.data.selected
})
}).then(function () {
self.setData({ purchasing: false })
wx.redirectTo({ url: '/pages/subscribe-success/subscribe-success' })
}).catch(function () {
self.setData({ purchasing: false })
wx.showToast({ title: '购买失败', icon: 'none' })
})
}
})
@@ -0,0 +1,3 @@
{
"navigationBarTitleText": "订阅套餐"
}
@@ -0,0 +1,15 @@
<view class="container">
<view wx:for="{{plans}}" wx:key="plan"
class="card plan-card {{selected === item.plan ? 'plan-active' : ''}}"
bindtap="onSelect" data-plan="{{item.plan}}">
<view class="plan-name">{{item.name}}</view>
<view class="plan-price">{{item.price}}</view>
<view class="plan-desc text-muted">{{item.desc}}</view>
</view>
<view class="btn-area-fixed">
<view class="btn-primary" bindtap="onPurchase" disabled="{{purchasing || !selected}}">
{{purchasing ? '处理中...' : '立即订阅'}}
</view>
</view>
</view>
@@ -0,0 +1,27 @@
.plan-card {
text-align: center;
border: 2rpx solid #eeeeee;
transition: border-color 0.2s;
}
.plan-active {
border-color: #333333;
background-color: #fafafa;
}
.plan-name {
font-size: 32rpx;
font-weight: 600;
margin-bottom: 12rpx;
}
.plan-price {
font-size: 48rpx;
font-weight: 700;
color: #333333;
margin-bottom: 8rpx;
}
.plan-desc {
font-size: 24rpx;
}
@@ -0,0 +1,11 @@
Page({
data: {},
onViewPlans: function () {
wx.navigateTo({ url: '/pages/subscribe-plans/subscribe-plans' })
},
onBack: function () {
wx.switchTab({ url: '/pages/index/index' })
}
})
@@ -0,0 +1,3 @@
{
"navigationBarTitleText": "订阅提示"
}
@@ -0,0 +1,9 @@
<view class="container">
<view class="card text-center">
<view class="lock-icon">&#128274;</view>
<view class="section-title">需要订阅</view>
<view class="text-muted mb-30">您的试用已到期,请订阅后继续使用</view>
<view class="btn-primary mb-20" bindtap="onViewPlans">查看套餐</view>
<view class="btn-secondary" bindtap="onBack">返回首页</view>
</view>
</view>
@@ -0,0 +1,4 @@
.lock-icon {
font-size: 80rpx;
margin: 40rpx auto;
}
@@ -0,0 +1,7 @@
Page({
data: {},
onBackHome: function () {
wx.switchTab({ url: '/pages/index/index' })
}
})
@@ -0,0 +1,3 @@
{
"navigationBarTitleText": "订阅成功"
}
@@ -0,0 +1,8 @@
<view class="container">
<view class="card text-center">
<view class="success-icon">&#10003;</view>
<view class="section-title">订阅成功</view>
<view class="text-muted mb-30">您已成功订阅,现在可以开始使用</view>
<view class="btn-primary" bindtap="onBackHome">返回首页</view>
</view>
</view>
@@ -0,0 +1,11 @@
.success-icon {
width: 120rpx;
height: 120rpx;
border-radius: 50%;
background-color: #52c41a;
color: #ffffff;
font-size: 72rpx;
line-height: 120rpx;
text-align: center;
margin: 40rpx auto;
}
+96
查看文件
@@ -0,0 +1,96 @@
var ble = require('../../services/ble')
Page({
data: {
regions: 0,
wavelength: 2,
duration: 600000,
mode: 0,
remainingMs: 600000,
remainingText: '10:00',
battery: 0,
temperature: 0,
progress: 0,
paused: false,
completed: false
},
onLoad: function (options) {
this.setData({
regions: parseInt(options.regions) || 0x7F,
wavelength: parseInt(options.wavelength) || 2,
duration: parseInt(options.duration) || 600000,
mode: parseInt(options.mode) || 0,
remainingMs: parseInt(options.duration) || 600000
})
ble.on('status', this.onStatus.bind(this))
ble.on('treatment_complete', this.onComplete.bind(this))
ble.on('exception', this.onException.bind(this))
},
onUnload: function () {
ble.off('status')
ble.off('treatment_complete')
ble.off('exception')
},
onStatus: function (status) {
var remaining = status.remaining_ms || 0
var total = this.data.duration
var progress = total > 0 ? Math.round(((total - remaining) / total) * 100) : 0
var mins = Math.floor(remaining / 60000)
var secs = Math.floor((remaining % 60000) / 1000)
var text = ('0' + mins).slice(-2) + ':' + ('0' + secs).slice(-2)
this.setData({
remainingMs: remaining,
remainingText: text,
progress: progress,
battery: status.battery,
temperature: status.temperature,
paused: status.mode_state === 0x03
})
},
onComplete: function (result) {
this.setData({ completed: true, progress: 100 })
var app = getApp()
app.globalData.currentTreatment = result
setTimeout(function () {
wx.redirectTo({
url: '/pages/treatment-done/treatment-done?session_id=' + result.session_id +
'&regions=' + result.regions +
'&duration=' + result.total_duration_ms +
'&avg_pd=' + result.avg_pd
})
}, 1000)
},
onException: function (err) {
wx.showModal({
title: '设备异常',
content: err.error_msg || '护理过程中出现异常',
showCancel: false,
complete: function () {
wx.navigateBack()
}
})
},
onStop: function () {
var self = this
wx.showModal({
title: '结束护理',
content: '确定要提前结束本次护理吗?',
success: function (res) {
if (res.confirm) {
ble.stopTreatment().then(function () {
wx.navigateBack()
})
}
}
})
}
})
+3
查看文件
@@ -0,0 +1,3 @@
{
"navigationBarTitleText": "护理中"
}
+33
查看文件
@@ -0,0 +1,33 @@
<view class="container treating-page">
<view class="card text-center">
<view class="progress-circle">
<text class="progress-text">{{remainingText}}</text>
</view>
<view class="progress-bar-wrap mt-20">
<progress percent="{{progress}}" stroke-width="8" activeColor="#333333" />
</view>
</view>
<view class="card">
<view class="flex-between mb-20">
<text>波长</text>
<text class="text-muted">{{wavelength === 1 ? '红外 850nm' : wavelength === 2 ? '红光 630nm' : wavelength === 3 ? '紫光 405nm' : '黄光 590nm'}}</text>
</view>
<view class="flex-between mb-20">
<text>模式</text>
<text class="text-muted">{{mode === 1 ? '智能模式' : '普通模式'}}</text>
</view>
<view class="flex-between mb-20">
<text>电量</text>
<text class="text-muted">{{battery}}%</text>
</view>
<view class="flex-between">
<text>温度</text>
<text class="text-muted">{{temperature}}°C</text>
</view>
</view>
<view class="stop-btn-area">
<view class="btn-stop" bindtap="onStop">结束护理</view>
</view>
</view>
+37
查看文件
@@ -0,0 +1,37 @@
.treating-page {
display: flex;
flex-direction: column;
min-height: 100vh;
}
.progress-circle {
width: 240rpx;
height: 240rpx;
border-radius: 50%;
border: 12rpx solid #eeeeee;
border-top-color: #333333;
display: flex;
align-items: center;
justify-content: center;
margin: 40rpx auto;
}
.progress-text {
font-size: 56rpx;
font-weight: 700;
color: #333333;
}
.stop-btn-area {
margin-top: auto;
padding: 20rpx 0 60rpx;
}
.btn-stop {
background-color: #ff4d4f;
color: #ffffff;
border-radius: 12rpx;
padding: 24rpx 40rpx;
text-align: center;
font-size: 30rpx;
}
@@ -0,0 +1,57 @@
var http = require('../../utils/request')
var ble = require('../../services/ble')
var app = getApp()
Page({
data: {
sessionId: '',
regions: 0,
duration: 0,
avgPd: 0,
durationText: '',
regionNames: [],
syncing: false,
synced: false
},
onLoad: function (options) {
var durationMs = parseInt(options.duration) || 0
var mins = Math.floor(durationMs / 60000)
var secs = Math.floor((durationMs % 60000) / 1000)
this.setData({
sessionId: options.session_id || '',
regions: parseInt(options.regions) || 0,
duration: durationMs,
avgPd: options.avg_pd || 0,
durationText: mins + '分' + secs + '秒',
regionNames: ble.getRegionName(parseInt(options.regions) || 0)
})
this.syncRecord()
},
syncRecord: function () {
var self = this
self.setData({ syncing: true })
http.post('/api/v1/treatment/sync', {
session_id: self.data.sessionId,
device_id: ble.getDeviceId(),
start_time: new Date(Date.now() - self.data.duration).toISOString(),
end_time: new Date().toISOString(),
regions: self.data.regions,
total_duration_ms: self.data.duration,
mode: 0,
avg_pd: self.data.avgPd
}).then(function () {
self.setData({ syncing: false, synced: true })
}).catch(function () {
self.setData({ syncing: false, synced: false })
})
},
onBackHome: function () {
wx.switchTab({ url: '/pages/index/index' })
}
})
@@ -0,0 +1,3 @@
{
"navigationBarTitleText": "护理完成"
}
@@ -0,0 +1,34 @@
<view class="container">
<view class="card text-center">
<view class="done-icon">&#10003;</view>
<view class="section-title">护理完成</view>
</view>
<view class="card">
<view class="flex-between mb-20">
<text>护理时长</text>
<text>{{durationText}}</text>
</view>
<view class="flex-between mb-20">
<text>护理区域</text>
<text class="text-muted">{{regionNames.join('、')}}</text>
</view>
<view class="flex-between">
<text>平均光功率密度</text>
<text>{{avgPd}}</text>
</view>
</view>
<view class="card text-center">
<view wx:if="{{syncing}}" class="text-muted">
<view class="spinner-sm"></view>
<text>正在同步记录...</text>
</view>
<view wx:elif="{{synced}}" class="text-success">记录已同步</view>
<view wx:else class="text-error">同步失败,可稍后在护理记录中重试</view>
</view>
<view class="btn-area-fixed">
<view class="btn-primary" bindtap="onBackHome">返回首页</view>
</view>
</view>
@@ -0,0 +1,25 @@
.done-icon {
width: 120rpx;
height: 120rpx;
border-radius: 50%;
background-color: #52c41a;
color: #ffffff;
font-size: 72rpx;
line-height: 120rpx;
text-align: center;
margin: 40rpx auto;
}
.spinner-sm {
width: 40rpx;
height: 40rpx;
border: 4rpx solid #eeeeee;
border-top-color: #333333;
border-radius: 50%;
animation: spin 0.8s linear infinite;
margin: 0 auto 12rpx;
}
@keyframes spin {
to { transform: rotate(360deg); }
}
@@ -0,0 +1,102 @@
var ble = require('../../services/ble')
Page({
data: {
regions: [
{ name: '左脸颊', mask: 0x01, checked: true },
{ name: '右脸颊', mask: 0x02, checked: true },
{ name: '额头', mask: 0x04, checked: true },
{ name: '下巴', mask: 0x08, checked: true },
{ name: '鼻部', mask: 0x10, checked: true },
{ name: '左眼周', mask: 0x20, checked: false },
{ name: '右眼周', mask: 0x40, checked: false }
],
wavelengthOptions: [
{ name: '红光 630nm', value: 2, desc: '抗衰修复' },
{ name: '红外 850nm', value: 1, desc: '深层修复' },
{ name: '紫光 405nm', value: 3, desc: '祛痘消炎' },
{ name: '黄光 590nm', value: 4, desc: '提亮肤色' }
],
selectedWavelength: 2,
brightness: 200,
durationOptions: [
{ label: '5分钟', value: 300000 },
{ label: '10分钟', value: 600000 },
{ label: '15分钟', value: 900000 },
{ label: '20分钟', value: 1200000 }
],
selectedDuration: 600000,
modeOptions: [
{ name: '普通模式', value: 0 },
{ name: '智能模式', value: 1 }
],
selectedMode: 0,
submitting: false,
error: ''
},
onToggleRegion: function (e) {
var idx = e.currentTarget.dataset.idx
var regions = this.data.regions
regions[idx].checked = !regions[idx].checked
this.setData({ regions: regions })
},
onSelectAll: function () {
var regions = this.data.regions.map(function (r) {
return Object.assign({}, r, { checked: true })
})
this.setData({ regions: regions })
},
onSelectWavelength: function (e) {
this.setData({ selectedWavelength: e.currentTarget.dataset.value })
},
onBrightnessChange: function (e) {
this.setData({ brightness: parseInt(e.detail.value) })
},
onSelectDuration: function (e) {
this.setData({ selectedDuration: e.currentTarget.dataset.value })
},
onSelectMode: function (e) {
this.setData({ selectedMode: e.currentTarget.dataset.value })
},
onStart: function () {
var self = this
var mask = 0
self.data.regions.forEach(function (r) {
if (r.checked) mask |= r.mask
})
if (mask === 0) {
wx.showToast({ title: '请至少选择一个区域', icon: 'none' })
return
}
self.setData({ submitting: true, error: '' })
ble.setParams({
region_mask: mask,
wavelength: self.data.selectedWavelength,
brightness: self.data.brightness,
duration_ms: self.data.selectedDuration,
mode: self.data.selectedMode
}).then(function () {
return ble.startTreatment(mask)
}).then(function () {
self.setData({ submitting: false })
wx.redirectTo({
url: '/pages/treating/treating?regions=' + mask +
'&wavelength=' + self.data.selectedWavelength +
'&duration=' + self.data.selectedDuration +
'&mode=' + self.data.selectedMode
})
}).catch(function (err) {
self.setData({ submitting: false, error: err.error_msg || '启动失败' })
})
}
})
@@ -0,0 +1,3 @@
{
"navigationBarTitleText": "护理设置"
}
@@ -0,0 +1,62 @@
<view class="container">
<view class="card">
<view class="section-title">护理区域</view>
<view class="region-grid">
<view wx:for="{{regions}}" wx:key="mask" class="region-item {{item.checked ? 'region-active' : ''}}"
bindtap="onToggleRegion" data-idx="{{index}}">
<text>{{item.name}}</text>
</view>
</view>
<view class="btn-select-all mt-10" bindtap="onSelectAll">全选</view>
</view>
<view class="card">
<view class="section-title">波长选择</view>
<view class="wavelength-list">
<view wx:for="{{wavelengthOptions}}" wx:key="value"
class="wavelength-item {{selectedWavelength === item.value ? 'wavelength-active' : ''}}"
bindtap="onSelectWavelength" data-value="{{item.value}}">
<text class="wavelength-name">{{item.name}}</text>
<text class="wavelength-desc">{{item.desc}}</text>
</view>
</view>
</view>
<view class="card">
<view class="section-title">亮度: {{brightness}}</view>
<slider min="0" max="255" value="{{brightness}}" bindchange="onBrightnessChange"
activeColor="#333333" block-size="20" />
</view>
<view class="card">
<view class="section-title">护理时长</view>
<view class="duration-list">
<view wx:for="{{durationOptions}}" wx:key="value"
class="duration-item {{selectedDuration === item.value ? 'duration-active' : ''}}"
bindtap="onSelectDuration" data-value="{{item.value}}">
{{item.label}}
</view>
</view>
</view>
<view class="card">
<view class="section-title">护理模式</view>
<view class="mode-list">
<view wx:for="{{modeOptions}}" wx:key="value"
class="mode-item {{selectedMode === item.value ? 'mode-active' : ''}}"
bindtap="onSelectMode" data-value="{{item.value}}">
{{item.name}}
</view>
</view>
</view>
<view wx:if="{{error}}" class="card">
<text class="text-error">{{error}}</text>
</view>
<view class="btn-area-fixed">
<view class="btn-primary" bindtap="onStart" disabled="{{submitting}}">
{{submitting ? '启动中...' : '开始护理'}}
</view>
</view>
</view>
@@ -0,0 +1,98 @@
.region-grid {
display: flex;
flex-wrap: wrap;
gap: 16rpx;
}
.region-item {
padding: 12rpx 24rpx;
border-radius: 8rpx;
background-color: #f0f0f0;
font-size: 26rpx;
color: #666666;
}
.region-active {
background-color: #333333;
color: #ffffff;
}
.btn-select-all {
font-size: 24rpx;
color: #333333;
text-align: right;
}
.wavelength-list {
display: flex;
flex-direction: column;
gap: 12rpx;
}
.wavelength-item {
padding: 20rpx;
border: 2rpx solid #eeeeee;
border-radius: 8rpx;
display: flex;
justify-content: space-between;
align-items: center;
}
.wavelength-active {
border-color: #333333;
background-color: #fafafa;
}
.wavelength-name {
font-size: 28rpx;
font-weight: 500;
}
.wavelength-desc {
font-size: 24rpx;
color: #999999;
}
.duration-list {
display: flex;
gap: 16rpx;
}
.duration-item {
flex: 1;
padding: 16rpx;
text-align: center;
border: 2rpx solid #eeeeee;
border-radius: 8rpx;
font-size: 26rpx;
}
.duration-active {
border-color: #333333;
background-color: #333333;
color: #ffffff;
}
.mode-list {
display: flex;
gap: 16rpx;
}
.mode-item {
flex: 1;
padding: 16rpx;
text-align: center;
border: 2rpx solid #eeeeee;
border-radius: 8rpx;
font-size: 26rpx;
}
.mode-active {
border-color: #333333;
background-color: #333333;
color: #ffffff;
}
.btn-area-fixed {
padding: 20rpx 0 40rpx;
}
@@ -0,0 +1,39 @@
var ble = require('../../services/ble')
Page({
data: {
checking: false,
result: null,
error: ''
},
onShow: function () {
this.checkWearing()
},
checkWearing: function () {
var self = this
self.setData({ checking: true, result: null, error: '' })
ble.on('status', function (status) {
self.setData({ checking: false })
if (status.bind_status === 1) {
self.setData({ result: 'ok' })
} else {
self.setData({ result: 'fail', error: '请确认设备已正确佩戴' })
}
})
ble.queryStatus().catch(function (err) {
self.setData({ checking: false, result: 'fail', error: err.error_msg || '查询失败' })
})
},
onNext: function () {
wx.navigateTo({ url: '/pages/treatment-setup/treatment-setup' })
},
onRetry: function () {
this.checkWearing()
}
})
@@ -0,0 +1,3 @@
{
"navigationBarTitleText": "确认佩戴"
}
@@ -0,0 +1,27 @@
<view class="container">
<view class="card text-center">
<view class="section-title">确认佩戴</view>
<view wx:if="{{checking}}" class="text-muted mb-30">
<view class="loading-area">
<view class="spinner-sm"></view>
<text>正在检测佩戴状态...</text>
</view>
<view class="tip-area mt-20">
<text class="text-muted">请将设备贴合面部,确保佩戴稳固</text>
</view>
</view>
<view wx:if="{{result === 'ok'}}">
<view class="success-mark">&#10003;</view>
<view class="text-success mb-30">佩戴确认成功</view>
<view class="btn-primary" bindtap="onNext">设置护理参数</view>
</view>
<view wx:if="{{result === 'fail'}}">
<view class="fail-mark">&#10007;</view>
<view class="text-error mb-20">{{error}}</view>
<view class="btn-primary" bindtap="onRetry">重新检测</view>
</view>
</view>
</view>
@@ -0,0 +1,50 @@
.loading-area {
display: flex;
flex-direction: column;
align-items: center;
padding: 40rpx 0;
}
.spinner-sm {
width: 48rpx;
height: 48rpx;
border: 4rpx solid #eeeeee;
border-top-color: #333333;
border-radius: 50%;
animation: spin 0.8s linear infinite;
margin-bottom: 16rpx;
}
@keyframes spin {
to { transform: rotate(360deg); }
}
.success-mark {
width: 80rpx;
height: 80rpx;
border-radius: 50%;
background-color: #52c41a;
color: #ffffff;
font-size: 48rpx;
line-height: 80rpx;
text-align: center;
margin: 20rpx auto;
}
.fail-mark {
width: 80rpx;
height: 80rpx;
border-radius: 50%;
background-color: #ff4d4f;
color: #ffffff;
font-size: 48rpx;
line-height: 80rpx;
text-align: center;
margin: 20rpx auto;
}
.tip-area {
padding: 20rpx;
background-color: #fffbe6;
border-radius: 8rpx;
}
+56
查看文件
@@ -0,0 +1,56 @@
{
"description": "项目配置文件",
"packOptions": {
"ignore": [],
"include": []
},
"setting": {
"bundle": false,
"userConfirmedBundleSwitch": false,
"urlCheck": true,
"scopeDataCheck": false,
"coverView": true,
"es6": true,
"postcss": true,
"compileHotReLoad": false,
"lazyloadPlaceholderEnable": false,
"preloadBackgroundData": false,
"minified": true,
"autoAudits": false,
"newFeature": false,
"uglifyFileName": false,
"uploadWithSourceMap": true,
"useIsolateContext": true,
"nodeModules": false,
"enhance": true,
"useMultiFrameRuntime": true,
"useApiHook": true,
"useApiHostProcess": true,
"showShadowRootInWxmlPanel": true,
"packNpmManually": false,
"enableEngineNative": false,
"packNpmRelationList": [],
"minifyWXSS": true,
"showES6CompileOption": false,
"minifyWXML": true,
"babelSetting": {
"ignore": [],
"disablePlugins": [],
"outputPath": ""
},
"compileWorklet": false,
"localPlugins": false,
"disableUseStrict": false,
"useCompilerPlugins": false,
"condition": false,
"swc": false,
"disableSWC": true
},
"compileType": "miniprogram",
"libVersion": "3.15.2",
"appid": "wxc4045074ef298510",
"projectname": "hox-beauty",
"condition": {},
"simulatorPluginLibVersion": {},
"editorSetting": {}
}

某些文件未显示,因为此 diff 中更改的文件太多 显示更多