feat: init project with miniprogram, cloud functions and admin console
这个提交包含在:
+6
@@ -0,0 +1,6 @@
|
||||
node_modules/
|
||||
.DS_Store
|
||||
小程序及后台管理软件开发资料/
|
||||
软件系统说明.docx
|
||||
*.docx
|
||||
cloud/sql/
|
||||
@@ -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.
|
||||
@@ -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>
|
||||
@@ -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"
|
||||
}
|
||||
}
|
||||
@@ -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>
|
||||
@@ -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 }
|
||||
}
|
||||
@@ -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"
|
||||
}
|
||||
}
|
||||
@@ -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>
|
||||
@@ -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>
|
||||
@@ -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>
|
||||
@@ -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>
|
||||
@@ -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>
|
||||
@@ -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>
|
||||
@@ -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 }
|
||||
})
|
||||
@@ -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 }
|
||||
@@ -0,0 +1,6 @@
|
||||
import { defineConfig } from 'vite'
|
||||
import uni from '@dcloudio/vite-plugin-uni'
|
||||
|
||||
export default defineConfig({
|
||||
plugins: [uni()]
|
||||
})
|
||||
@@ -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
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
@@ -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'] || ''
|
||||
}
|
||||
@@ -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'] || ''
|
||||
}
|
||||
@@ -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'] || ''
|
||||
}
|
||||
@@ -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'
|
||||
}
|
||||
}
|
||||
@@ -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 || {}
|
||||
}
|
||||
@@ -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 || {}
|
||||
}
|
||||
@@ -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 || {}
|
||||
}
|
||||
@@ -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"
|
||||
}
|
||||
}
|
||||
@@ -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
|
||||
})
|
||||
})
|
||||
}
|
||||
})
|
||||
@@ -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;
|
||||
}
|
||||
@@ -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">✓</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); }
|
||||
}
|
||||
@@ -0,0 +1,8 @@
|
||||
Page({
|
||||
data: {
|
||||
articles: []
|
||||
},
|
||||
|
||||
onLoad: function () {
|
||||
}
|
||||
})
|
||||
@@ -0,0 +1,3 @@
|
||||
{
|
||||
"navigationBarTitleText": "发现"
|
||||
}
|
||||
@@ -0,0 +1,4 @@
|
||||
<view class="container">
|
||||
<view class="section-title">发现</view>
|
||||
<view class="text-center text-muted mt-20">暂无内容</view>
|
||||
</view>
|
||||
@@ -0,0 +1 @@
|
||||
{}
|
||||
@@ -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 })
|
||||
})
|
||||
}
|
||||
})
|
||||
@@ -0,0 +1,3 @@
|
||||
{
|
||||
"navigationBarTitleText": "护理记录"
|
||||
}
|
||||
@@ -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">📑</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>
|
||||
@@ -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;
|
||||
}
|
||||
@@ -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' })
|
||||
}
|
||||
}
|
||||
})
|
||||
@@ -0,0 +1,3 @@
|
||||
{
|
||||
"navigationBarTitleText": "首页"
|
||||
}
|
||||
@@ -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">❯</text>
|
||||
</view>
|
||||
</view>
|
||||
|
||||
<view class="card" wx:if="{{connected && subscription && subscription.status > 0}}">
|
||||
<view class="btn-primary text-center" bindtap="onStartTreatment">开始护理</view>
|
||||
</view>
|
||||
</view>
|
||||
@@ -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;
|
||||
}
|
||||
@@ -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' })
|
||||
})
|
||||
}
|
||||
})
|
||||
@@ -0,0 +1,4 @@
|
||||
{
|
||||
"navigationBarTitleText": "授权登录",
|
||||
"navigationStyle": "custom"
|
||||
}
|
||||
@@ -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>
|
||||
@@ -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;
|
||||
}
|
||||
@@ -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' })
|
||||
}
|
||||
}
|
||||
})
|
||||
}
|
||||
})
|
||||
@@ -0,0 +1,3 @@
|
||||
{
|
||||
"navigationBarTitleText": "我的"
|
||||
}
|
||||
@@ -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">❯</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">❯</text>
|
||||
</view>
|
||||
</view>
|
||||
<view class="divider"></view>
|
||||
<view class="menu-item" bindtap="onViewHistory">
|
||||
<text>护理记录</text>
|
||||
<text class="arrow-sm">❯</text>
|
||||
</view>
|
||||
</view>
|
||||
|
||||
<view class="card">
|
||||
<view class="menu-item logout" bindtap="onLogout">
|
||||
<text>退出登录</text>
|
||||
</view>
|
||||
</view>
|
||||
</view>
|
||||
@@ -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;
|
||||
}
|
||||
@@ -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 || '绑定失败'
|
||||
})
|
||||
})
|
||||
}
|
||||
})
|
||||
@@ -0,0 +1,3 @@
|
||||
{
|
||||
"navigationBarTitleText": "扫码绑定"
|
||||
}
|
||||
@@ -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>
|
||||
@@ -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">🔒</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">✓</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;
|
||||
}
|
||||
@@ -0,0 +1,96 @@
|
||||
var ble = require('../../services/ble')
|
||||
|
||||
Page({
|
||||
data: {
|
||||
regions: 0,
|
||||
wavelength: 2,
|
||||
duration: 600000,
|
||||
mode: 0,
|
||||
remainingMs: 600000,
|
||||
remainingText: '10:00',
|
||||
battery: 0,
|
||||
temperature: 0,
|
||||
progress: 0,
|
||||
paused: false,
|
||||
completed: false
|
||||
},
|
||||
|
||||
onLoad: function (options) {
|
||||
this.setData({
|
||||
regions: parseInt(options.regions) || 0x7F,
|
||||
wavelength: parseInt(options.wavelength) || 2,
|
||||
duration: parseInt(options.duration) || 600000,
|
||||
mode: parseInt(options.mode) || 0,
|
||||
remainingMs: parseInt(options.duration) || 600000
|
||||
})
|
||||
|
||||
ble.on('status', this.onStatus.bind(this))
|
||||
ble.on('treatment_complete', this.onComplete.bind(this))
|
||||
ble.on('exception', this.onException.bind(this))
|
||||
},
|
||||
|
||||
onUnload: function () {
|
||||
ble.off('status')
|
||||
ble.off('treatment_complete')
|
||||
ble.off('exception')
|
||||
},
|
||||
|
||||
onStatus: function (status) {
|
||||
var remaining = status.remaining_ms || 0
|
||||
var total = this.data.duration
|
||||
var progress = total > 0 ? Math.round(((total - remaining) / total) * 100) : 0
|
||||
var mins = Math.floor(remaining / 60000)
|
||||
var secs = Math.floor((remaining % 60000) / 1000)
|
||||
var text = ('0' + mins).slice(-2) + ':' + ('0' + secs).slice(-2)
|
||||
|
||||
this.setData({
|
||||
remainingMs: remaining,
|
||||
remainingText: text,
|
||||
progress: progress,
|
||||
battery: status.battery,
|
||||
temperature: status.temperature,
|
||||
paused: status.mode_state === 0x03
|
||||
})
|
||||
},
|
||||
|
||||
onComplete: function (result) {
|
||||
this.setData({ completed: true, progress: 100 })
|
||||
var app = getApp()
|
||||
app.globalData.currentTreatment = result
|
||||
|
||||
setTimeout(function () {
|
||||
wx.redirectTo({
|
||||
url: '/pages/treatment-done/treatment-done?session_id=' + result.session_id +
|
||||
'®ions=' + result.regions +
|
||||
'&duration=' + result.total_duration_ms +
|
||||
'&avg_pd=' + result.avg_pd
|
||||
})
|
||||
}, 1000)
|
||||
},
|
||||
|
||||
onException: function (err) {
|
||||
wx.showModal({
|
||||
title: '设备异常',
|
||||
content: err.error_msg || '护理过程中出现异常',
|
||||
showCancel: false,
|
||||
complete: function () {
|
||||
wx.navigateBack()
|
||||
}
|
||||
})
|
||||
},
|
||||
|
||||
onStop: function () {
|
||||
var self = this
|
||||
wx.showModal({
|
||||
title: '结束护理',
|
||||
content: '确定要提前结束本次护理吗?',
|
||||
success: function (res) {
|
||||
if (res.confirm) {
|
||||
ble.stopTreatment().then(function () {
|
||||
wx.navigateBack()
|
||||
})
|
||||
}
|
||||
}
|
||||
})
|
||||
}
|
||||
})
|
||||
@@ -0,0 +1,3 @@
|
||||
{
|
||||
"navigationBarTitleText": "护理中"
|
||||
}
|
||||
@@ -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>
|
||||
@@ -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">✓</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">✓</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">✗</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;
|
||||
}
|
||||
@@ -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 中更改的文件太多 显示更多
在新工单中引用
屏蔽一个用户