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

这个提交包含在:
Guoguo
2026-04-22 21:24:20 +08:00
当前提交 a84e37a6e2
修改 106 个文件,包含 5902 行新增0 行删除
+14
查看文件
@@ -0,0 +1,14 @@
<!DOCTYPE html>
<html lang="zh-CN">
<head>
<meta charset="UTF-8" />
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
<title>光子美容仪 - 管理后台</title>
<!--preload-links-->
<!--app-context-->
</head>
<body>
<div id="app"><!--app-html--></div>
<script type="module" src="/src/main.js"></script>
</body>
</html>
+25
查看文件
@@ -0,0 +1,25 @@
{
"name": "hox-admin-console",
"version": "1.0.0",
"private": true,
"scripts": {
"dev": "uni",
"build:h5": "uni build -p h5"
},
"dependencies": {
"@dcloudio/uni-app": "3.0.0-4020920240930001",
"@dcloudio/uni-app-plus": "3.0.0-4020920240930001",
"@dcloudio/uni-components": "3.0.0-4020920240930001",
"@dcloudio/uni-h5": "3.0.0-4020920240930001",
"vue": "^3.4.0",
"uview-plus": "^3.2.0",
"pinia": "^2.1.0"
},
"devDependencies": {
"@dcloudio/uni-automator": "3.0.0-4020920240930001",
"@dcloudio/uni-cli-shared": "3.0.0-4020920240930001",
"@dcloudio/vite-plugin-uni": "3.0.0-4020920240930001",
"vite": "^5.0.0",
"sass": "^1.77.0"
}
}
+21
查看文件
@@ -0,0 +1,21 @@
<script>
import { useUserStore } from './store/user'
export default {
onLaunch() {
const userStore = useUserStore()
if (!userStore.isLoggedIn()) {
uni.reLaunch({ url: '/pages/login/index' })
}
}
}
</script>
<style>
page {
background-color: #f5f5f5;
font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, 'Helvetica Neue', Arial, sans-serif;
font-size: 14px;
color: #333333;
}
</style>
+10
查看文件
@@ -0,0 +1,10 @@
import { createSSRApp } from 'vue'
import { createPinia } from 'pinia'
import App from './App.vue'
export function createApp() {
const app = createSSRApp(App)
const pinia = createPinia()
app.use(pinia)
return { app }
}
+50
查看文件
@@ -0,0 +1,50 @@
{
"pages": [
{
"path": "pages/login/index",
"style": { "navigationBarTitleText": "管理员登录", "navigationStyle": "custom" }
},
{
"path": "pages/dashboard/index",
"style": { "navigationBarTitleText": "仪表盘" }
},
{
"path": "pages/device/index",
"style": { "navigationBarTitleText": "设备管理" }
},
{
"path": "pages/device-detail/index",
"style": { "navigationBarTitleText": "设备详情" }
},
{
"path": "pages/user/index",
"style": { "navigationBarTitleText": "用户管理" }
},
{
"path": "pages/user-detail/index",
"style": { "navigationBarTitleText": "用户详情" }
},
{
"path": "pages/subscription/index",
"style": { "navigationBarTitleText": "订阅管理" }
},
{
"path": "pages/record/index",
"style": { "navigationBarTitleText": "护理记录" }
},
{
"path": "pages/log/index",
"style": { "navigationBarTitleText": "操作日志" }
},
{
"path": "pages/settings/index",
"style": { "navigationBarTitleText": "系统设置" }
}
],
"globalStyle": {
"navigationBarTextStyle": "black",
"navigationBarTitleText": "光子美容仪后台",
"navigationBarBackgroundColor": "#ffffff",
"backgroundColor": "#f5f5f5"
}
}
+150
查看文件
@@ -0,0 +1,150 @@
<template>
<view class="container">
<view class="nav-bar">
<text class="nav-item" v-for="item in navItems" :key="item.path"
:class="{ active: currentNav === item.path }" @click="onNav(item.path)">
{{ item.label }}
</text>
<text class="nav-item logout" @click="onLogout">退出</text>
</view>
<view class="stat-grid">
<view class="stat-card">
<text class="stat-value">{{ stats.user_count || 0 }}</text>
<text class="stat-label">用户数</text>
</view>
<view class="stat-card">
<text class="stat-value">{{ stats.device_count || 0 }}</text>
<text class="stat-label">设备数</text>
</view>
<view class="stat-card">
<text class="stat-value">{{ stats.active_device_count || 0 }}</text>
<text class="stat-label">激活设备</text>
</view>
<view class="stat-card">
<text class="stat-value">{{ stats.subscription_count || 0 }}</text>
<text class="stat-label">有效订阅</text>
</view>
<view class="stat-card">
<text class="stat-value">{{ stats.treatment_count || 0 }}</text>
<text class="stat-label">护理次数</text>
</view>
<view class="stat-card">
<text class="stat-value">{{ stats.today_treatment_count || 0 }}</text>
<text class="stat-label">今日护理</text>
</view>
</view>
</view>
</template>
<script>
import { get } from '../../utils/request'
import { useUserStore } from '../../store/user'
export default {
data() {
return {
stats: {},
currentNav: 'dashboard',
navItems: [
{ path: 'dashboard', label: '仪表盘' },
{ path: 'device', label: '设备' },
{ path: 'user', label: '用户' },
{ path: 'subscription', label: '订阅' },
{ path: 'record', label: '记录' },
{ path: 'log', label: '日志' },
{ path: 'settings', label: '设置' }
]
}
},
onShow() {
this.loadDashboard()
},
methods: {
async loadDashboard() {
try {
this.stats = await get('/api/v1/admin/dashboard')
} catch (e) {}
},
onNav(path) {
const routeMap = {
dashboard: '/pages/dashboard/index',
device: '/pages/device/index',
user: '/pages/user/index',
subscription: '/pages/subscription/index',
record: '/pages/record/index',
log: '/pages/log/index',
settings: '/pages/settings/index'
}
if (routeMap[path] && path !== 'dashboard') {
uni.navigateTo({ url: routeMap[path] })
}
},
onLogout() {
const userStore = useUserStore()
userStore.clearToken()
uni.reLaunch({ url: '/pages/login/index' })
}
}
}
</script>
<style scoped>
.container {
padding: 16px;
}
.nav-bar {
display: flex;
flex-wrap: wrap;
gap: 8px;
margin-bottom: 24px;
padding: 12px;
background: #ffffff;
border-radius: 8px;
}
.nav-item {
padding: 6px 16px;
font-size: 14px;
cursor: pointer;
border-radius: 4px;
color: #666666;
}
.nav-item.active {
background: #333333;
color: #ffffff;
}
.nav-item.logout {
margin-left: auto;
color: #ff4d4f;
}
.stat-grid {
display: grid;
grid-template-columns: repeat(3, 1fr);
gap: 16px;
}
.stat-card {
background: #ffffff;
border-radius: 8px;
padding: 24px 16px;
text-align: center;
}
.stat-value {
display: block;
font-size: 32px;
font-weight: 700;
color: #333333;
margin-bottom: 8px;
}
.stat-label {
font-size: 13px;
color: #999999;
}
</style>
@@ -0,0 +1,143 @@
<template>
<view class="container">
<view class="card" v-if="device">
<view class="card-title">设备信息</view>
<view class="info-row">
<text class="info-label">设备ID</text>
<text>{{ device.device_id }}</text>
</view>
<view class="info-row">
<text class="info-label">设备名称</text>
<text>{{ device.device_name || '-' }}</text>
</view>
<view class="info-row">
<text class="info-label">硬件版本</text>
<text>{{ device.hw_version || '-' }}</text>
</view>
<view class="info-row">
<text class="info-label">固件版本</text>
<text>{{ device.fw_version || '-' }}</text>
</view>
<view class="info-row">
<text class="info-label">状态</text>
<text>{{ statusMap[device.status] || '未知' }}</text>
</view>
<view class="info-row">
<text class="info-label">激活时间</text>
<text>{{ device.activated_at || '-' }}</text>
</view>
</view>
<view class="card" v-if="device && device.binding_history">
<view class="card-title">绑定记录</view>
<view v-for="(b, idx) in device.binding_history" :key="idx" class="binding-item">
<view class="info-row">
<text class="info-label">用户</text>
<text>{{ b.nickname || '-' }}</text>
</view>
<view class="info-row">
<text class="info-label">绑定时间</text>
<text>{{ formatDate(b.bound_at) }}</text>
</view>
<view class="info-row" v-if="b.unbound_at">
<text class="info-label">解绑时间</text>
<text>{{ formatDate(b.unbound_at) }}</text>
</view>
<view class="info-row">
<text class="info-label">状态</text>
<text :class="b.status === 1 ? 'text-success' : 'text-muted'">
{{ b.status === 1 ? '有效' : '已解绑' }}
</text>
</view>
<view class="divider" v-if="idx < device.binding_history.length - 1"></view>
</view>
</view>
<view class="card">
<view class="card-title">远程控制</view>
<u-button type="primary" size="small" @click="onCommand('query')">查询状态</u-button>
</view>
</view>
</template>
<script>
import { get, post } from '../../utils/request'
export default {
data() {
return {
device: null,
deviceId: '',
statusMap: { 1: '未激活', 2: '已激活', 3: '禁用', 4: '故障' }
}
},
onLoad(options) {
this.deviceId = options.device_id || ''
this.loadDevice()
},
methods: {
async loadDevice() {
try {
this.device = await get('/api/v1/admin/devices/' + this.deviceId)
} catch (e) {
uni.showToast({ title: '加载失败', icon: 'none' })
}
},
async onCommand(type) {
try {
await post('/api/v1/admin/devices/' + this.deviceId + '/command', { opcode: type })
uni.showToast({ title: '指令已发送', icon: 'success' })
} catch (e) {
uni.showToast({ title: '发送失败', icon: 'none' })
}
},
formatDate(d) {
return d ? d.slice(0, 19).replace('T', ' ') : '-'
}
}
}
</script>
<style scoped>
.container {
padding: 16px;
}
.card {
background: #ffffff;
border-radius: 8px;
padding: 16px;
margin-bottom: 16px;
}
.card-title {
font-size: 16px;
font-weight: 600;
margin-bottom: 12px;
}
.info-row {
display: flex;
justify-content: space-between;
padding: 8px 0;
font-size: 14px;
}
.info-label {
color: #999999;
}
.divider {
height: 1px;
background: #eeeeee;
margin: 8px 0;
}
.text-success {
color: #52c41a;
}
.text-muted {
color: #999999;
}
</style>
+116
查看文件
@@ -0,0 +1,116 @@
<template>
<view class="container">
<view class="toolbar">
<u-search v-model="keyword" placeholder="搜索设备ID或名称" @search="onSearch" @clear="onSearch" />
</view>
<view class="table-wrap">
<u-table>
<u-tr>
<u-th>设备ID</u-th>
<u-th>名称</u-th>
<u-th>状态</u-th>
<u-th>绑定用户</u-th>
<u-th>激活时间</u-th>
</u-tr>
<u-tr v-for="item in devices" :key="item.device_id" @click="onDetail(item.device_id)">
<u-td>{{ item.device_id }}</u-td>
<u-td>{{ item.device_name || '-' }}</u-td>
<u-td>
<text :class="item.status === 2 ? 'text-success' : 'text-muted'">
{{ statusMap[item.status] || '未知' }}
</text>
</u-td>
<u-td>{{ item.bound_user || '-' }}</u-td>
<u-td>{{ formatDate(item.activated_at) }}</u-td>
</u-tr>
</u-table>
</view>
<view class="pagination">
<u-button size="small" :disabled="page <= 1" @click="onPage(page - 1)">上一页</u-button>
<text class="page-info"> {{ page }} / {{ totalPages }} </text>
<u-button size="small" :disabled="page >= totalPages" @click="onPage(page + 1)">下一页</u-button>
</view>
</view>
</template>
<script>
import { get } from '../../utils/request'
export default {
data() {
return {
devices: [],
total: 0,
page: 1,
pageSize: 20,
keyword: '',
statusMap: { 1: '未激活', 2: '已激活', 3: '禁用', 4: '故障' }
}
},
computed: {
totalPages() {
return Math.ceil(this.total / this.pageSize) || 1
}
},
onShow() {
this.loadDevices()
},
methods: {
async loadDevices() {
try {
const data = await get('/api/v1/admin/devices', { page: this.page, page_size: this.pageSize })
this.devices = data.records || []
this.total = data.total || 0
} catch (e) {}
},
onSearch() {
this.page = 1
this.loadDevices()
},
onPage(p) {
this.page = p
this.loadDevices()
},
onDetail(deviceId) {
uni.navigateTo({ url: '/pages/device-detail/index?device_id=' + deviceId })
},
formatDate(d) {
return d ? d.slice(0, 10) : '-'
}
}
}
</script>
<style scoped>
.container {
padding: 16px;
}
.toolbar {
margin-bottom: 16px;
}
.pagination {
display: flex;
align-items: center;
justify-content: center;
gap: 16px;
margin-top: 16px;
padding: 12px;
}
.page-info {
font-size: 13px;
color: #999999;
}
.text-success {
color: #52c41a;
}
.text-muted {
color: #999999;
}
</style>
+112
查看文件
@@ -0,0 +1,112 @@
<template>
<view class="container">
<view class="toolbar">
<view class="filter-row">
<u-input v-model="filters.type" placeholder="操作类型" class="filter-input" />
<u-input v-model="filters.device_id" placeholder="设备ID" class="filter-input" />
<u-button type="primary" size="small" @click="onSearch">查询</u-button>
</view>
</view>
<view class="table-wrap">
<u-table>
<u-tr>
<u-th>操作者</u-th>
<u-th>对象类型</u-th>
<u-th>对象ID</u-th>
<u-th>操作</u-th>
<u-th>时间</u-th>
</u-tr>
<u-tr v-for="item in logs" :key="item.id">
<u-td>{{ item.operator_type === 1 ? '管理员' : '系统' }}</u-td>
<u-td>{{ item.target_type }}</u-td>
<u-td>{{ item.target_id || '-' }}</u-td>
<u-td>{{ item.action }}</u-td>
<u-td>{{ formatDate(item.created_at) }}</u-td>
</u-tr>
</u-table>
</view>
<view class="pagination">
<u-button size="small" :disabled="page <= 1" @click="onPage(page - 1)">上一页</u-button>
<text class="page-info"> {{ page }} / {{ totalPages }} </text>
<u-button size="small" :disabled="page >= totalPages" @click="onPage(page + 1)">下一页</u-button>
</view>
</view>
</template>
<script>
import { get } from '../../utils/request'
export default {
data() {
return {
logs: [],
total: 0,
page: 1,
pageSize: 20,
filters: { type: '', device_id: '' }
}
},
computed: {
totalPages() {
return Math.ceil(this.total / this.pageSize) || 1
}
},
onShow() {
this.loadLogs()
},
methods: {
async loadLogs() {
try {
var params = { page: this.page, page_size: this.pageSize }
if (this.filters.type) params.type = this.filters.type
if (this.filters.device_id) params.device_id = this.filters.device_id
const data = await get('/api/v1/admin/logs', params)
this.logs = data.records || []
this.total = data.total || 0
} catch (e) {}
},
onSearch() {
this.page = 1
this.loadLogs()
},
onPage(p) {
this.page = p
this.loadLogs()
},
formatDate(d) {
return d ? d.slice(0, 19).replace('T', ' ') : '-'
}
}
}
</script>
<style scoped>
.container {
padding: 16px;
}
.filter-row {
display: flex;
gap: 8px;
align-items: center;
}
.filter-input {
width: 120px;
}
.pagination {
display: flex;
align-items: center;
justify-content: center;
gap: 16px;
margin-top: 16px;
}
.page-info {
font-size: 13px;
color: #999999;
}
</style>
+86
查看文件
@@ -0,0 +1,86 @@
<template>
<view class="login-page">
<view class="login-card">
<view class="login-title">光子美容仪</view>
<view class="login-subtitle">管理后台</view>
<u-form :model="form" ref="formRef">
<u-form-item>
<u-input v-model="form.username" placeholder="用户名" border="bottom" />
</u-form-item>
<u-form-item>
<u-input v-model="form.password" placeholder="密码" type="password" border="bottom" />
</u-form-item>
</u-form>
<u-button type="primary" :loading="loading" @click="onLogin" :disabled="loading" class="login-btn">
登录
</u-button>
</view>
</view>
</template>
<script>
import { useUserStore } from '../../store/user'
export default {
data() {
return {
form: { username: '', password: '' },
loading: false
}
},
methods: {
async onLogin() {
if (!this.form.username || !this.form.password) {
uni.showToast({ title: '请输入用户名和密码', icon: 'none' })
return
}
this.loading = true
try {
const userStore = useUserStore()
await userStore.login(this.form)
uni.reLaunch({ url: '/pages/dashboard/index' })
} catch (e) {
uni.showToast({ title: e.message || '登录失败', icon: 'none' })
} finally {
this.loading = false
}
}
}
}
</script>
<style scoped>
.login-page {
min-height: 100vh;
display: flex;
align-items: center;
justify-content: center;
background: linear-gradient(135deg, #333333 0%, #666666 100%);
}
.login-card {
width: 400px;
background: #ffffff;
border-radius: 12px;
padding: 48px 32px;
}
.login-title {
font-size: 28px;
font-weight: 700;
text-align: center;
margin-bottom: 8px;
}
.login-subtitle {
font-size: 16px;
color: #999999;
text-align: center;
margin-bottom: 32px;
}
.login-btn {
margin-top: 24px;
background-color: #333333;
}
</style>
+112
查看文件
@@ -0,0 +1,112 @@
<template>
<view class="container">
<view class="toolbar flex-between">
<text class="total-text"> {{ total }} 条记录</text>
</view>
<view class="table-wrap">
<u-table>
<u-tr>
<u-th>会话ID</u-th>
<u-th>用户ID</u-th>
<u-th>区域</u-th>
<u-th>时长</u-th>
<u-th>时间</u-th>
</u-tr>
<u-tr v-for="item in records" :key="item.record_id">
<u-td>{{ item.session_id ? item.session_id.slice(0, 8) : '-' }}</u-td>
<u-td>{{ item.user_id }}</u-td>
<u-td>{{ formatRegions(item.regions) }}</u-td>
<u-td>{{ Math.floor((item.total_duration_ms || 0) / 60000) }}分钟</u-td>
<u-td>{{ formatDate(item.start_time) }}</u-td>
</u-tr>
</u-table>
</view>
<view class="pagination">
<u-button size="small" :disabled="page <= 1" @click="onPage(page - 1)">上一页</u-button>
<text class="page-info"> {{ page }} / {{ totalPages }} </text>
<u-button size="small" :disabled="page >= totalPages" @click="onPage(page + 1)">下一页</u-button>
</view>
</view>
</template>
<script>
import { get } from '../../utils/request'
var REGION_MAP = {
1: '左脸', 2: '右脸', 4: '额头', 8: '下巴', 16: '鼻', 32: '左眼', 64: '右眼'
}
export default {
data() {
return {
records: [],
total: 0,
page: 1,
pageSize: 20
}
},
computed: {
totalPages() {
return Math.ceil(this.total / this.pageSize) || 1
}
},
onShow() {
this.loadRecords()
},
methods: {
async loadRecords() {
try {
const data = await get('/api/v1/admin/logs', {
type: 'treatment',
page: this.page,
page_size: this.pageSize
})
this.records = data.records || []
this.total = data.total || 0
} catch (e) {}
},
onPage(p) {
this.page = p
this.loadRecords()
},
formatRegions(mask) {
if (!mask) return '-'
var names = []
var bits = [1, 2, 4, 8, 16, 32, 64]
for (var i = 0; i < bits.length; i++) {
if (mask & bits[i]) names.push(REGION_MAP[bits[i]])
}
return names.join(',') || '-'
},
formatDate(d) {
return d ? d.slice(0, 10) : '-'
}
}
}
</script>
<style scoped>
.container {
padding: 16px;
}
.total-text {
font-size: 13px;
color: #999999;
}
.pagination {
display: flex;
align-items: center;
justify-content: center;
gap: 16px;
margin-top: 16px;
}
.page-info {
font-size: 13px;
color: #999999;
}
</style>
@@ -0,0 +1,94 @@
<template>
<view class="container">
<view class="card">
<view class="card-title">系统设置</view>
<view class="setting-section">
<view class="section-label">管理员管理</view>
<view class="info-row">
<text>当前仅支持通过数据库管理管理员账号</text>
</view>
</view>
<view class="divider"></view>
<view class="setting-section">
<view class="section-label">环境变量</view>
<view class="info-row">
<text class="info-label">API地址</text>
<text>https://api.lightmask.com</text>
</view>
<view class="info-row">
<text class="info-label">MQTT Broker</text>
<text>mqtt://iotcloud.tencent.com:8883</text>
</view>
<view class="info-row">
<text class="info-label">JWT有效期</text>
<text>7</text>
</view>
</view>
</view>
<view class="card">
<view class="card-title">关于</view>
<view class="info-row">
<text class="info-label">系统版本</text>
<text>1.0.0</text>
</view>
<view class="info-row">
<text class="info-label">协议版本</text>
<text>v1.0</text>
</view>
</view>
</view>
</template>
<script>
export default {}
</script>
<style scoped>
.container {
padding: 16px;
}
.card {
background: #ffffff;
border-radius: 8px;
padding: 16px;
margin-bottom: 16px;
}
.card-title {
font-size: 16px;
font-weight: 600;
margin-bottom: 16px;
}
.setting-section {
margin-bottom: 16px;
}
.section-label {
font-size: 14px;
font-weight: 500;
margin-bottom: 12px;
}
.info-row {
display: flex;
justify-content: space-between;
padding: 8px 0;
font-size: 14px;
}
.info-label {
color: #999999;
}
.divider {
height: 1px;
background: #eeeeee;
margin: 16px 0;
}
</style>
@@ -0,0 +1,165 @@
<template>
<view class="container">
<view class="toolbar flex-between">
<text class="total-text"> {{ total }} </text>
<u-button type="primary" size="small" @click="showCreate = true">创建订阅</u-button>
</view>
<view class="table-wrap">
<u-table>
<u-tr>
<u-th>用户ID</u-th>
<u-th>昵称</u-th>
<u-th>类型</u-th>
<u-th>方案</u-th>
<u-th>状态</u-th>
<u-th>到期时间</u-th>
</u-tr>
<u-tr v-for="item in subscriptions" :key="item.id">
<u-td>{{ item.user_id }}</u-td>
<u-td>{{ item.nickname || '-' }}</u-td>
<u-td>{{ subTypeMap[item.type] || '-' }}</u-td>
<u-td>{{ item.plan || '-' }}</u-td>
<u-td>
<text :class="item.status === 1 ? 'text-success' : 'text-muted'">
{{ subStatusMap[item.status] || '-' }}
</text>
</u-td>
<u-td>{{ formatDate(item.expired_at) }}</u-td>
</u-tr>
</u-table>
</view>
<view class="pagination">
<u-button size="small" :disabled="page <= 1" @click="onPage(page - 1)">上一页</u-button>
<text class="page-info"> {{ page }} / {{ totalPages }} </text>
<u-button size="small" :disabled="page >= totalPages" @click="onPage(page + 1)">下一页</u-button>
</view>
<u-popup :show="showCreate" @close="showCreate = false" mode="center">
<view class="create-form">
<view class="form-title">创建订阅</view>
<u-form :model="createForm">
<u-form-item label="用户ID">
<u-input v-model="createForm.user_id" placeholder="输入用户ID" />
</u-form-item>
<u-form-item label="方案">
<u-input v-model="createForm.plan" placeholder="monthly/quarterly/yearly" />
</u-form-item>
<u-form-item label="天数">
<u-input v-model="createForm.days" placeholder="订阅天数" type="number" />
</u-form-item>
</u-form>
<u-button type="primary" @click="onCreate" :loading="creating">创建</u-button>
</view>
</u-popup>
</view>
</template>
<script>
import { get, post } from '../../utils/request'
export default {
data() {
return {
subscriptions: [],
total: 0,
page: 1,
pageSize: 20,
showCreate: false,
creating: false,
createForm: { user_id: '', plan: 'monthly', days: 30 },
subTypeMap: { 1: '试用', 2: '正式', 3: '赠送' },
subStatusMap: { 1: '有效', 2: '已过期', 3: '已停用' }
}
},
computed: {
totalPages() {
return Math.ceil(this.total / this.pageSize) || 1
}
},
onShow() {
this.loadSubscriptions()
},
methods: {
async loadSubscriptions() {
try {
const data = await get('/api/v1/admin/subscriptions', { page: this.page, page_size: this.pageSize })
this.subscriptions = data.records || []
this.total = data.total || 0
} catch (e) {}
},
onPage(p) {
this.page = p
this.loadSubscriptions()
},
async onCreate() {
this.creating = true
try {
await post('/api/v1/admin/subscriptions', {
user_id: parseInt(this.createForm.user_id),
plan: this.createForm.plan,
days: parseInt(this.createForm.days)
})
this.showCreate = false
this.loadSubscriptions()
uni.showToast({ title: '创建成功', icon: 'success' })
} catch (e) {
uni.showToast({ title: '创建失败', icon: 'none' })
} finally {
this.creating = false
}
},
formatDate(d) {
return d ? d.slice(0, 10) : '-'
}
}
}
</script>
<style scoped>
.container {
padding: 16px;
}
.toolbar {
margin-bottom: 16px;
}
.total-text {
font-size: 13px;
color: #999999;
}
.pagination {
display: flex;
align-items: center;
justify-content: center;
gap: 16px;
margin-top: 16px;
}
.page-info {
font-size: 13px;
color: #999999;
}
.create-form {
padding: 24px;
width: 400px;
}
.form-title {
font-size: 18px;
font-weight: 600;
margin-bottom: 16px;
}
.text-success {
color: #52c41a;
}
.text-muted {
color: #999999;
}
</style>
@@ -0,0 +1,152 @@
<template>
<view class="container">
<view class="card" v-if="user">
<view class="card-title">用户信息</view>
<view class="info-row">
<text class="info-label">用户ID</text>
<text>{{ user.user_id }}</text>
</view>
<view class="info-row">
<text class="info-label">昵称</text>
<text>{{ user.nickname || '-' }}</text>
</view>
<view class="info-row">
<text class="info-label">手机号</text>
<text>{{ user.phone || '-' }}</text>
</view>
<view class="info-row">
<text class="info-label">注册时间</text>
<text>{{ formatDate(user.created_at) }}</text>
</view>
</view>
<view class="card" v-if="user && user.devices">
<view class="card-title">绑定设备</view>
<view v-for="(d, idx) in user.devices" :key="idx" class="device-item">
<view class="info-row">
<text class="info-label">设备</text>
<text>{{ d.device_name || d.device_id }}</text>
</view>
<view class="info-row">
<text class="info-label">绑定时间</text>
<text>{{ formatDate(d.bound_at) }}</text>
</view>
<view class="divider" v-if="idx < user.devices.length - 1"></view>
</view>
<view v-if="user.devices.length === 0" class="text-muted">暂无绑定设备</view>
</view>
<view class="card" v-if="user && user.subscriptions">
<view class="card-title">订阅记录</view>
<view v-for="(s, idx) in user.subscriptions" :key="idx">
<view class="info-row">
<text class="info-label">类型</text>
<text>{{ subTypeMap[s.type] || '未知' }}</text>
</view>
<view class="info-row">
<text class="info-label">状态</text>
<text :class="s.status === 1 ? 'text-success' : 'text-muted'">
{{ subStatusMap[s.status] || '未知' }}
</text>
</view>
<view class="info-row">
<text class="info-label">有效期</text>
<text>{{ formatDate(s.started_at) }} ~ {{ formatDate(s.expired_at) }}</text>
</view>
<view class="divider" v-if="idx < user.subscriptions.length - 1"></view>
</view>
<view v-if="user.subscriptions.length === 0" class="text-muted">暂无订阅</view>
</view>
<view class="card" v-if="user && user.recent_treatments">
<view class="card-title">最近护理</view>
<view v-for="(t, idx) in user.recent_treatments" :key="idx">
<view class="info-row">
<text class="info-label">时间</text>
<text>{{ formatDate(t.started_at) }}</text>
</view>
<view class="info-row">
<text class="info-label">时长</text>
<text>{{ Math.floor((t.total_duration_ms || 0) / 60000) }}分钟</text>
</view>
<view class="divider" v-if="idx < user.recent_treatments.length - 1"></view>
</view>
<view v-if="user.recent_treatments.length === 0" class="text-muted">暂无记录</view>
</view>
</view>
</template>
<script>
import { get } from '../../utils/request'
export default {
data() {
return {
user: null,
userId: '',
subTypeMap: { 1: '试用', 2: '正式', 3: '赠送' },
subStatusMap: { 1: '有效', 2: '已过期', 3: '已停用' }
}
},
onLoad(options) {
this.userId = options.user_id || ''
this.loadUser()
},
methods: {
async loadUser() {
try {
this.user = await get('/api/v1/admin/users/' + this.userId)
} catch (e) {
uni.showToast({ title: '加载失败', icon: 'none' })
}
},
formatDate(d) {
return d ? d.slice(0, 10) : '-'
}
}
}
</script>
<style scoped>
.container {
padding: 16px;
}
.card {
background: #ffffff;
border-radius: 8px;
padding: 16px;
margin-bottom: 16px;
}
.card-title {
font-size: 16px;
font-weight: 600;
margin-bottom: 12px;
}
.info-row {
display: flex;
justify-content: space-between;
padding: 8px 0;
font-size: 14px;
}
.info-label {
color: #999999;
}
.divider {
height: 1px;
background: #eeeeee;
margin: 8px 0;
}
.text-success {
color: #52c41a;
}
.text-muted {
color: #999999;
}
</style>
+102
查看文件
@@ -0,0 +1,102 @@
<template>
<view class="container">
<view class="toolbar">
<u-search v-model="keyword" placeholder="搜索用户昵称" @search="onSearch" @clear="onSearch" />
</view>
<view class="table-wrap">
<u-table>
<u-tr>
<u-th>用户ID</u-th>
<u-th>昵称</u-th>
<u-th>设备数</u-th>
<u-th>订阅到期</u-th>
<u-th>注册时间</u-th>
</u-tr>
<u-tr v-for="item in users" :key="item.user_id" @click="onDetail(item.user_id)">
<u-td>{{ item.user_id }}</u-td>
<u-td>{{ item.nickname || '-' }}</u-td>
<u-td>{{ item.device_count || 0 }}</u-td>
<u-td>{{ formatDate(item.subscription_expire) }}</u-td>
<u-td>{{ formatDate(item.created_at) }}</u-td>
</u-tr>
</u-table>
</view>
<view class="pagination">
<u-button size="small" :disabled="page <= 1" @click="onPage(page - 1)">上一页</u-button>
<text class="page-info"> {{ page }} / {{ totalPages }} </text>
<u-button size="small" :disabled="page >= totalPages" @click="onPage(page + 1)">下一页</u-button>
</view>
</view>
</template>
<script>
import { get } from '../../utils/request'
export default {
data() {
return {
users: [],
total: 0,
page: 1,
pageSize: 20,
keyword: ''
}
},
computed: {
totalPages() {
return Math.ceil(this.total / this.pageSize) || 1
}
},
onShow() {
this.loadUsers()
},
methods: {
async loadUsers() {
try {
const data = await get('/api/v1/admin/users', { page: this.page, page_size: this.pageSize })
this.users = data.records || []
this.total = data.total || 0
} catch (e) {}
},
onSearch() {
this.page = 1
this.loadUsers()
},
onPage(p) {
this.page = p
this.loadUsers()
},
onDetail(userId) {
uni.navigateTo({ url: '/pages/user-detail/index?user_id=' + userId })
},
formatDate(d) {
return d ? d.slice(0, 10) : '-'
}
}
}
</script>
<style scoped>
.container {
padding: 16px;
}
.toolbar {
margin-bottom: 16px;
}
.pagination {
display: flex;
align-items: center;
justify-content: center;
gap: 16px;
margin-top: 16px;
}
.page-info {
font-size: 13px;
color: #999999;
}
</style>
+37
查看文件
@@ -0,0 +1,37 @@
import { defineStore } from 'pinia'
import { ref } from 'vue'
import { post } from '../utils/request'
export const useUserStore = defineStore('user', () => {
const token = ref(uni.getStorageSync('admin_token') || '')
const adminInfo = ref(null)
function setToken(val) {
token.value = val
uni.setStorageSync('admin_token', val)
}
function clearToken() {
token.value = ''
adminInfo.value = null
uni.removeStorageSync('admin_token')
}
async function login(credentials) {
const data = await post('/api/v1/admin/login', credentials)
setToken(data.token)
adminInfo.value = {
admin_id: data.admin_id,
username: data.username,
real_name: data.real_name,
role: data.role
}
return data
}
function isLoggedIn() {
return !!token.value
}
return { token, adminInfo, setToken, clearToken, login, isLoggedIn }
})
+46
查看文件
@@ -0,0 +1,46 @@
const BASE_URL = 'https://api.lightmask.com'
function request(options) {
const token = uni.getStorageSync('admin_token')
return new Promise((resolve, reject) => {
uni.request({
url: BASE_URL + options.url,
method: options.method || 'GET',
data: options.data || {},
header: {
'Authorization': token ? 'Bearer ' + token : '',
'Content-Type': 'application/json',
...options.header
},
success(res) {
if (res.data && res.data.code === 0) {
resolve(res.data.data)
} else if (res.data && (res.data.code === 1001 || res.data.code === 1002)) {
uni.removeStorageSync('admin_token')
uni.reLaunch({ url: '/pages/login/index' })
reject(res.data)
} else {
reject(res.data || { code: -1, message: '请求失败' })
}
},
fail(err) {
reject({ code: -1, message: err.errMsg || '网络异常' })
}
})
})
}
function get(url, data) {
return request({ url, method: 'GET', data })
}
function post(url, data) {
return request({ url, method: 'POST', data })
}
function put(url, data) {
return request({ url, method: 'PUT', data })
}
export { request, get, post, put, BASE_URL }
+6
查看文件
@@ -0,0 +1,6 @@
import { defineConfig } from 'vite'
import uni from '@dcloudio/vite-plugin-uni'
export default defineConfig({
plugins: [uni()]
})