refactor: migrate to Tencent Cloud backend

这个提交包含在:
Guoguo
2026-04-28 22:56:47 +08:00
父节点 267c75718b
当前提交 444c91c0b0
修改 102 个文件,包含 17927 行新增1997 行删除
+11861
查看文件
文件差异内容过多而无法显示 加载差异
+8 -5
查看文件
@@ -4,22 +4,25 @@
"private": true,
"scripts": {
"dev": "uni",
"build:h5": "uni build -p h5"
"build:h5": "uni build -p h5",
"deploy:cos": "npm run build:h5 && node scripts/deploy-cos.js"
},
"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",
"pinia": "^2.1.0",
"uview-plus": "^3.2.0",
"pinia": "^2.1.0"
"vue": "^3.4.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"
"cos-nodejs-sdk-v5": "^2.15.4",
"dotenv": "^17.4.2",
"sass": "^1.77.0",
"vite": "^5.0.0"
}
}
+78
查看文件
@@ -0,0 +1,78 @@
const fs = require('fs')
const path = require('path')
const COS = require('cos-nodejs-sdk-v5')
const root = path.join(__dirname, '..', 'dist', 'build', 'h5')
const serverEnv = path.join(__dirname, '..', '..', 'server', '.env')
if (fs.existsSync(serverEnv)) {
require('dotenv').config({ path: serverEnv })
}
const bucket = process.env.COS_BUCKET
const region = process.env.COS_REGION || process.env.TENCENT_REGION
const secretId = process.env.TENCENT_SECRET_ID
const secretKey = process.env.TENCENT_SECRET_KEY
const prefix = process.env.ADMIN_COS_PREFIX || 'admin/'
if (!bucket || !region || !secretId || !secretKey) {
console.error('COS_BUCKET, COS_REGION, TENCENT_SECRET_ID and TENCENT_SECRET_KEY are required')
process.exit(1)
}
if (!fs.existsSync(root)) {
console.error('Build output not found: ' + root)
process.exit(1)
}
const cos = new COS({ SecretId: secretId, SecretKey: secretKey })
function walk(dir) {
const files = []
fs.readdirSync(dir).forEach(name => {
const file = path.join(dir, name)
const stat = fs.statSync(file)
if (stat.isDirectory()) files.push.apply(files, walk(file))
else files.push(file)
})
return files
}
function contentType(file) {
if (file.endsWith('.html')) return 'text/html; charset=utf-8'
if (file.endsWith('.js')) return 'application/javascript; charset=utf-8'
if (file.endsWith('.css')) return 'text/css; charset=utf-8'
if (file.endsWith('.json')) return 'application/json; charset=utf-8'
if (file.endsWith('.svg')) return 'image/svg+xml'
if (file.endsWith('.png')) return 'image/png'
if (file.endsWith('.jpg') || file.endsWith('.jpeg')) return 'image/jpeg'
return 'application/octet-stream'
}
async function upload(file) {
const relative = path.relative(root, file).replace(/\\/g, '/')
const key = prefix + relative
await new Promise((resolve, reject) => {
cos.putObject({
Bucket: bucket,
Region: region,
Key: key,
Body: fs.createReadStream(file),
ContentType: contentType(file)
}, err => err ? reject(err) : resolve())
})
console.log('uploaded ' + key)
}
async function main() {
const files = walk(root)
for (const file of files) {
await upload(file)
}
console.log('Admin console uploaded to cos://' + bucket + '/' + prefix)
}
main().catch(err => {
console.error(err.message || err)
process.exit(1)
})
+10
查看文件
@@ -0,0 +1,10 @@
const ENV = 'test'
const API_BASES = {
local: 'http://localhost:3000',
test: 'https://1426323813-ilxkhlxf4p.ap-guangzhou.tencentscf.com',
prod: 'https://replace-with-scf-prod-url'
}
export { ENV, API_BASES }
export default { ENV, API_BASE: API_BASES[ENV] }
+13
查看文件
@@ -0,0 +1,13 @@
{
"name": "光子美容仪后台",
"appid": "__UNI__HOXADMIN",
"description": "Hox beauty device admin console",
"versionName": "1.0.0",
"versionCode": "100",
"h5": {
"title": "光子美容仪后台",
"router": {
"mode": "hash"
}
}
}
+40 -3
查看文件
@@ -10,7 +10,8 @@
@confirm="onSearch"
/>
<button class="btn-primary btn-sm" @click="onSearch">搜索</button>
<button class="btn-default btn-sm">导出</button>
<button class="btn-primary btn-sm" @click="onCreateDevice">预生成产品码</button>
<button class="btn-default btn-sm" @click="onExport">导出</button>
</view>
</view>
@@ -19,6 +20,7 @@
<view class="t-header">
<view class="t-row">
<text class="t-th flex2">设备编号</text>
<text class="t-th flex2">产品编号</text>
<text class="t-th flex2">绑定用户</text>
<text class="t-th flex1">电量</text>
<text class="t-th flex1">固件版本</text>
@@ -30,9 +32,10 @@
<view class="t-body">
<view class="t-row" v-for="item in devices" :key="item.device_id">
<text class="t-td flex2">{{ item.device_id }}</text>
<text class="t-td flex2">{{ item.product_id || 'HOX_LIGHT_MASK' }}</text>
<text class="t-td flex2">{{ item.bound_user || '-' }}</text>
<text class="t-td flex1">{{ item.battery != null ? item.battery + '%' : '-' }}</text>
<text class="t-td flex1">{{ item.fw_version || '-' }}</text>
<text class="t-td flex1">{{ item.firmware_version || item.fw_version || '-' }}</text>
<text class="t-td flex2">{{ formatDate(item.activated_at) }}</text>
<text class="t-td flex1">
<text :class="statusBadge(item.status)">{{ statusMap[item.status] || '未知' }}</text>
@@ -56,7 +59,8 @@
</template>
<script>
import { get } from '../../utils/request'
import { get, post } from '../../utils/request'
import { exportCSV } from '../../utils/export'
import AdminLayout from '../../components/AdminLayout.vue'
export default {
@@ -98,12 +102,45 @@ export default {
onDetail(deviceId) {
uni.navigateTo({ url: '/pages/device-detail/index?device_id=' + deviceId })
},
onCreateDevice() {
const self = this
uni.showModal({
title: '预生成产品码',
editable: true,
placeholderText: '请输入设备MAC/设备编号',
success: async function (res) {
if (!res.confirm || !res.content) return
try {
await post('/api/v1/admin/devices', { device_id: res.content.trim(), product_id: 'HOX_LIGHT_MASK' })
uni.showToast({ title: '创建成功', icon: 'success' })
self.loadDevices()
} catch (e) {
uni.showToast({ title: '创建失败', icon: 'none' })
}
}
})
},
statusBadge(status) {
const map = { 2: 'badge badge-success', 3: 'badge badge-warning', 1: 'badge badge-blue', 4: 'badge badge-error' }
return map[status] || 'badge badge-default'
},
formatDate(d) {
return d ? d.slice(0, 10) : '-'
},
async onExport() {
try {
const data = await get('/api/v1/admin/devices', { page: 1, page_size: 9999, keyword: this.keyword })
const records = data.records || []
exportCSV('devices_' + new Date().toISOString().slice(0, 10) + '.csv',
['设备编号', '产品编号', '绑定用户', '电量', '固件版本', '绑定时间', '状态'],
records.map(function (r) {
return [r.device_id, r.product_id || 'HOX_LIGHT_MASK', r.bound_user || '', r.battery != null ? r.battery + '%' : '', r.firmware_version || r.fw_version || '', r.activated_at ? String(r.activated_at).slice(0, 10) : '', r.status]
})
)
uni.showToast({ title: '导出成功', icon: 'success' })
} catch (e) {
uni.showToast({ title: '导出失败', icon: 'none' })
}
}
}
}
+19 -1
查看文件
@@ -9,7 +9,7 @@
placeholder-class="input-placeholder"
/>
<button class="btn-primary btn-sm" @click="onSearch">搜索</button>
<button class="btn-default btn-sm">导出</button>
<button class="btn-default btn-sm" @click="onExport">导出</button>
</view>
</view>
@@ -40,6 +40,7 @@
<script>
import { get } from '../../utils/request'
import { exportCSV } from '../../utils/export'
import AdminLayout from '../../components/AdminLayout.vue'
export default {
@@ -97,6 +98,23 @@ export default {
},
formatDate(d) {
return d ? d.slice(0, 19).replace('T', ' ') : '-'
},
async onExport() {
try {
var params = { page: 1, page_size: 9999 }
if (this.filters.type) params.type = this.filters.type
const data = await get('/api/v1/admin/logs', params)
const records = data.records || []
exportCSV('logs_' + new Date().toISOString().slice(0, 10) + '.csv',
['时间', '操作类型', '操作详情', '操作者'],
records.map(function (r) {
return [r.created_at ? r.created_at.slice(0, 19).replace('T', ' ') : '', r.action || '', r.detail || '', r.openid || '']
})
)
uni.showToast({ title: '导出成功', icon: 'success' })
} catch (e) {
uni.showToast({ title: '导出失败', icon: 'none' })
}
}
}
}
+1 -1
查看文件
@@ -50,7 +50,7 @@ import { useUserStore } from '../../store/user'
export default {
data() {
return {
form: { username: '', password: '' },
form: { username: 'admin', password: 'admin' },
loading: false,
remember: false
}
+22 -1
查看文件
@@ -13,7 +13,7 @@
@confirm="onSearch"
/>
<button class="btn-primary btn-sm" @click="onSearch">搜索</button>
<button class="btn-default btn-sm">导出</button>
<button class="btn-default btn-sm" @click="onExport">导出</button>
</view>
</view>
@@ -63,6 +63,7 @@
<script>
import { get } from '../../utils/request'
import { exportCSV } from '../../utils/export'
import AdminLayout from '../../components/AdminLayout.vue'
var REGION_MAP = {
@@ -125,6 +126,26 @@ export default {
},
formatDate(d) {
return d ? d.slice(0, 10) : '-'
},
async onExport() {
try {
const data = await get('/api/v1/admin/records', { page: 1, page_size: 9999, keyword: this.keyword, date_from: this.dateFrom, date_to: this.dateTo })
var self = this
var REGION_MAP = { 1: '左脸', 2: '右脸', 4: '额头', 8: '下巴', 16: '鼻', 32: '左眼', 64: '右眼' }
const records = data.records || []
exportCSV('records_' + new Date().toISOString().slice(0, 10) + '.csv',
['记录ID', '用户', '设备编号', '护理模式', '护理区域', '护理时长(分钟)', '护理时间'],
records.map(function (r) {
var regions = []
var bits = [1, 2, 4, 8, 16, 32, 64]
for (var i = 0; i < bits.length; i++) { if (r.regions & bits[i]) regions.push(REGION_MAP[bits[i]]) }
return [r.record_id || '', r.nickname || r.user_id || '', r.device_id || '', r.mode === 'smart' ? '智能模式' : '普通模式', regions.join('+'), Math.floor((r.total_duration_ms || 0) / 60000), r.start_time ? r.start_time.slice(0, 10) : '']
})
)
uni.showToast({ title: '导出成功', icon: 'success' })
} catch (e) {
uni.showToast({ title: '导出失败', icon: 'none' })
}
}
}
}
+19 -1
查看文件
@@ -2,7 +2,7 @@
<AdminLayout currentPage="/pages/subscription/index">
<view class="toolbar">
<view class="header-actions">
<button class="btn-default btn-sm">导出报表</button>
<button class="btn-default btn-sm" @click="onExport">导出报表</button>
</view>
</view>
@@ -102,6 +102,7 @@
<script>
import { get, post } from '../../utils/request'
import { exportCSV } from '../../utils/export'
import AdminLayout from '../../components/AdminLayout.vue'
export default {
@@ -176,6 +177,23 @@ export default {
},
formatDate(d) {
return d ? d.slice(0, 10) : '-'
},
async onExport() {
try {
const data = await get('/api/v1/admin/subscriptions', { page: 1, page_size: 9999, tab: this.activeTab })
const records = data.records || []
var planMap = { monthly: '月卡', quarterly: '季卡', yearly: '年卡', trial: '试用' }
var statusMap = { 1: '生效中', 2: '试用中', 3: '已过期' }
exportCSV('subscriptions_' + new Date().toISOString().slice(0, 10) + '.csv',
['用户', '订阅类型', '订单金额', '开始日期', '到期日期', '状态'],
records.map(function (r) {
return [r.nickname || r.user_id || '', planMap[r.plan] || r.plan || '', r.amount || '', r.started_at ? r.started_at.slice(0, 10) : '', r.expired_at ? r.expired_at.slice(0, 10) : '', statusMap[r.status] || '']
})
)
uni.showToast({ title: '导出成功', icon: 'success' })
} catch (e) {
uni.showToast({ title: '导出失败', icon: 'none' })
}
}
}
}
+17 -1
查看文件
@@ -10,7 +10,7 @@
@confirm="onSearch"
/>
<button class="btn-primary btn-sm" @click="onSearch">搜索</button>
<button class="btn-default btn-sm">导出用户</button>
<button class="btn-default btn-sm" @click="onExport">导出用户</button>
</view>
</view>
@@ -62,6 +62,7 @@
<script>
import { get } from '../../utils/request'
import { exportCSV } from '../../utils/export'
import AdminLayout from '../../components/AdminLayout.vue'
export default {
@@ -117,6 +118,21 @@ export default {
},
formatDate(d) {
return d ? d.slice(0, 10) : '-'
},
async onExport() {
try {
const data = await get('/api/v1/admin/users', { page: 1, page_size: 9999, keyword: this.keyword })
const records = data.records || []
exportCSV('users_' + new Date().toISOString().slice(0, 10) + '.csv',
['用户ID', '昵称', '手机号', '绑定设备数', '护理次数', '订阅状态', '注册时间'],
records.map(function (r) {
return [r._id, r.nickname || '', r.phone || '', r.device_count || 0, r.treatment_count || 0, r.subscription_status || '', r.created_at ? r.created_at.slice(0, 10) : '']
})
)
uni.showToast({ title: '导出成功', icon: 'success' })
} catch (e) {
uni.showToast({ title: '导出失败', icon: 'none' })
}
}
}
}
+25
查看文件
@@ -0,0 +1,25 @@
function escapeCSV(val) {
if (val == null) return ''
var s = String(val)
if (s.indexOf(',') > -1 || s.indexOf('"') > -1 || s.indexOf('\n') > -1) {
return '"' + s.replace(/"/g, '""') + '"'
}
return s
}
function exportCSV(filename, headers, rows) {
var lines = [headers.map(escapeCSV).join(',')]
for (var i = 0; i < rows.length; i++) {
lines.push(rows[i].map(escapeCSV).join(','))
}
var csv = '\uFEFF' + lines.join('\n')
var blob = new Blob([csv], { type: 'text/csv;charset=utf-8;' })
var url = URL.createObjectURL(blob)
var a = document.createElement('a')
a.href = url
a.download = filename
a.click()
URL.revokeObjectURL(url)
}
export { exportCSV }
+11 -3
查看文件
@@ -1,5 +1,7 @@
const BASE_URL = 'https://api.lightmask.com'
const USE_MOCK = true
import env from '../config/env'
const BASE_URL = env.API_BASE
const USE_MOCK = false
const MOCK_DATA = {
'/api/v1/admin/devices/AABBCCDDEEFF0011': {
@@ -67,6 +69,12 @@ const MOCK_DATA = {
}
function getMockData(url, data) {
if (url.includes('/api/v1/admin/login')) {
if (data && data.username === 'admin' && data.password === 'admin123') {
return { token: 'mock_token_admin', admin_id: 'admin_001', username: 'admin', real_name: '管理员', role: 'admin' }
}
throw { code: 1001, message: '用户名或密码错误' }
}
if (url.startsWith('/api/v1/admin/users/') && !url.includes('users?')) {
const id = url.split('/').pop()
return MOCK_DATA['/api/v1/admin/users/u1'] || { _id: id, nickname: '未知用户' }
@@ -92,8 +100,8 @@ function request(options) {
method: options.method || 'GET',
data: options.data || {},
header: {
'Authorization': token ? 'Bearer ' + token : '',
'Content-Type': 'application/json',
'Authorization': token ? 'Bearer ' + token : '',
...options.header
},
success(res) {