diff --git a/admin-console/src/pages/device/index.vue b/admin-console/src/pages/device/index.vue
index d02c1ab..24d71be 100644
--- a/admin-console/src/pages/device/index.vue
+++ b/admin-console/src/pages/device/index.vue
@@ -11,6 +11,7 @@
/>
+
@@ -55,6 +56,21 @@
共 {{ totalPages }} 页
+
+
+
+ 批量导入设备
+
+ 设备编号(每行一个)
+
+
+ 最多 500 个设备
+
+
+
+
+
+
@@ -73,7 +89,10 @@ export default {
page: 1,
pageSize: 20,
keyword: '',
- statusMap: { 1: '未激活', 2: '在线', 3: '离线', 4: '故障' }
+ statusMap: { 1: '未激活', 2: '在线', 3: '离线', 4: '故障' },
+ showBatchImport: false,
+ batchDeviceIds: '',
+ batchImporting: false
}
},
computed: {
@@ -146,6 +165,29 @@ export default {
return map[status] || 'badge badge-default'
},
formatDate: formatDateShort,
+ async onBatchImport() {
+ const ids = this.batchDeviceIds.split('\n').map(s => s.trim()).filter(Boolean)
+ if (ids.length === 0) {
+ uni.showToast({ title: '请输入设备编号', icon: 'none' })
+ return
+ }
+ if (ids.length > 500) {
+ uni.showToast({ title: '最多 500 个', icon: 'none' })
+ return
+ }
+ this.batchImporting = true
+ try {
+ const result = await post('/api/v1/admin/devices/batch', { device_ids: ids })
+ uni.showToast({ title: '导入 ' + result.created + ' 个设备', icon: 'success' })
+ this.showBatchImport = false
+ this.batchDeviceIds = ''
+ this.loadDevices()
+ } catch (e) {
+ uni.showToast({ title: '导入失败', icon: 'none' })
+ } finally {
+ this.batchImporting = false
+ }
+ },
async onExport() {
try {
const data = await get('/api/v1/admin/devices', { page: 1, page_size: 9999, keyword: this.keyword })
@@ -171,4 +213,61 @@ export default {
.pagination {
justify-content: flex-end;
}
+
+.modal-mask {
+ position: fixed;
+ top: 0; left: 0; right: 0; bottom: 0;
+ background: rgba(0,0,0,0.45);
+ display: flex;
+ align-items: center;
+ justify-content: center;
+ z-index: 1000;
+}
+
+.modal-content {
+ width: 480px;
+ background: #fff;
+ border-radius: 8px;
+ padding: 24px;
+}
+
+.modal-title {
+ font-size: 18px;
+ font-weight: 600;
+ margin-bottom: 20px;
+}
+
+.form-group {
+ margin-bottom: 16px;
+}
+
+.form-label {
+ display: block;
+ font-size: 14px;
+ color: #333;
+ margin-bottom: 6px;
+}
+
+.form-textarea {
+ width: 100%;
+ height: 160px;
+ border: 1px solid #d9d9d9;
+ border-radius: 6px;
+ padding: 12px;
+ font-size: 14px;
+ box-sizing: border-box;
+ resize: vertical;
+}
+
+.batch-hint {
+ font-size: 12px;
+ color: #999;
+ margin-bottom: 16px;
+}
+
+.modal-actions {
+ display: flex;
+ justify-content: flex-end;
+ gap: 8px;
+}
diff --git a/admin-console/src/pages/settings/index.vue b/admin-console/src/pages/settings/index.vue
index 826862b..1991a8f 100644
--- a/admin-console/src/pages/settings/index.vue
+++ b/admin-console/src/pages/settings/index.vue
@@ -1,6 +1,28 @@
+
+ 修改密码
+
+
+ 当前密码
+
+
+
+ 新密码
+
+
+
+ 确认新密码
+
+
+
+
+
+
+
+
+
基础配置
@@ -123,7 +145,8 @@ export default {
enable_free_mode: true,
maintenance_mode: false
},
- timezoneOptions: ['Asia/Shanghai', 'Asia/Tokyo', 'America/New_York', 'Europe/London']
+ timezoneOptions: ['Asia/Shanghai', 'Asia/Tokyo', 'America/New_York', 'Europe/London'],
+ passwordForm: { old_password: '', new_password: '', confirm_password: '' }
}
},
onShow() {
@@ -153,6 +176,30 @@ export default {
},
onTimezoneChange(e) {
this.settings.timezone = this.timezoneOptions[e.detail.value]
+ },
+ async onChangePassword() {
+ if (!this.passwordForm.old_password || !this.passwordForm.new_password) {
+ uni.showToast({ title: '请填写完整', icon: 'none' })
+ return
+ }
+ if (this.passwordForm.new_password.length < 6) {
+ uni.showToast({ title: '新密码至少6位', icon: 'none' })
+ return
+ }
+ if (this.passwordForm.new_password !== this.passwordForm.confirm_password) {
+ uni.showToast({ title: '两次密码不一致', icon: 'none' })
+ return
+ }
+ try {
+ await post('/api/v1/admin/password', {
+ old_password: this.passwordForm.old_password,
+ new_password: this.passwordForm.new_password
+ })
+ uni.showToast({ title: '密码修改成功', icon: 'success' })
+ this.passwordForm = { old_password: '', new_password: '', confirm_password: '' }
+ } catch (e) {
+ uni.showToast({ title: e.message || '修改失败', icon: 'none' })
+ }
}
}
}
@@ -338,4 +385,11 @@ export default {
line-height: 36px;
cursor: pointer;
}
+
+.btn-sm {
+ height: 32px;
+ padding: 0 16px;
+ font-size: 14px;
+ line-height: 32px;
+}
diff --git a/miniprogram/app.wxss b/miniprogram/app.wxss
index bc0a557..66d451c 100644
--- a/miniprogram/app.wxss
+++ b/miniprogram/app.wxss
@@ -246,6 +246,10 @@ page {
margin-bottom: 8rpx;
}
+.nav-back-light {
+ color: rgba(255, 255, 255, 0.9);
+}
+
.debug-actions {
margin-top: 40rpx;
padding: 20rpx;
diff --git a/miniprogram/pages/index/index.js b/miniprogram/pages/index/index.js
index 31beb1c..6be4fee 100644
--- a/miniprogram/pages/index/index.js
+++ b/miniprogram/pages/index/index.js
@@ -1,5 +1,6 @@
var ble = require('../../services/ble')
var http = require('../../utils/request')
+var config = require('../../config/env')
var app = getApp()
Page({
@@ -12,10 +13,12 @@ Page({
modeState: '空闲',
bleState: 'disconnected',
hasDevice: false,
- deviceInfo: null
+ deviceInfo: null,
+ devMode: false
},
onShow: function () {
+ this.setData({ devMode: config.__DEV__ || false })
this.checkState()
this._onStatus = this.onBleStatus.bind(this)
ble.on('status', this._onStatus)
@@ -145,6 +148,30 @@ Page({
wx.navigateTo({ url: '/pages/wear-check/wear-check' })
},
+ onMockBind: function () {
+ var self = this
+ var mockDeviceId = 'MOCK_' + Date.now().toString(36).toUpperCase()
+ wx.showModal({
+ title: '模拟绑定',
+ content: '将创建一个模拟设备 ' + mockDeviceId + ' 并绑定到当前账号',
+ success: function (res) {
+ if (res.confirm) {
+ http.post('/api/v1/device/bind', { device_id: mockDeviceId }).then(function (data) {
+ return http.post('/api/v1/device/bind/confirm', {
+ device_id: data.device_id,
+ bind_token: data.bind_token
+ })
+ }).then(function () {
+ wx.showToast({ title: '模拟绑定成功', icon: 'success' })
+ self.checkState()
+ }).catch(function (err) {
+ wx.showToast({ title: err.message || '绑定失败', icon: 'none' })
+ })
+ }
+ }
+ })
+ },
+
onViewSubscription: function () {
if (!this.data.subscription || this.data.subscription.status !== 'active') {
wx.navigateTo({ url: '/pages/subscribe-plans/subscribe-plans' })
diff --git a/miniprogram/pages/index/index.wxml b/miniprogram/pages/index/index.wxml
index af97efa..eed3fea 100644
--- a/miniprogram/pages/index/index.wxml
+++ b/miniprogram/pages/index/index.wxml
@@ -4,6 +4,10 @@
📱
还没有绑定设备
+
+ 🔧 调试工具
+
+
diff --git a/miniprogram/pages/treating/treating.js b/miniprogram/pages/treating/treating.js
index 23e71e2..5cb304c 100644
--- a/miniprogram/pages/treating/treating.js
+++ b/miniprogram/pages/treating/treating.js
@@ -178,6 +178,10 @@ Page({
})
},
+ onBack: function () {
+ this.onStop()
+ },
+
onStop: function () {
var self = this
wx.showModal({
diff --git a/miniprogram/pages/treating/treating.wxml b/miniprogram/pages/treating/treating.wxml
index 7760e64..ff7e986 100644
--- a/miniprogram/pages/treating/treating.wxml
+++ b/miniprogram/pages/treating/treating.wxml
@@ -1,5 +1,6 @@
diff --git a/server/src/routes/admin.js b/server/src/routes/admin.js
index 5da2243..815a042 100644
--- a/server/src/routes/admin.js
+++ b/server/src/routes/admin.js
@@ -31,6 +31,25 @@ function register(router) {
return ok({ token, admin_id: String(admin.admin_id), username: admin.username, real_name: admin.real_name, role: admin.role })
})
+ router.post('/api/v1/admin/password', async ctx => {
+ const admin = await requireAdmin(ctx)
+ if (!admin) return fail(1002, '未授权,请重新登录')
+ const oldPassword = ctx.body.old_password || ''
+ const newPassword = ctx.body.new_password || ''
+ if (newPassword.length < 6) return fail(2001, 'password too short')
+ const current = await one('SELECT * FROM admin_accounts WHERE admin_id = :admin_id AND status = 1', { admin_id: admin.admin_id })
+ if (!current) return fail(1002, '未授权,请重新登录')
+ let matched = verifyPassword(oldPassword, current.password_hash)
+ if (!matched && current.password_salt && hashPasswordLegacy(oldPassword, current.password_salt) === current.password_hash) {
+ matched = true
+ }
+ if (!matched) return fail(1001, '原密码错误')
+ const newHash = hashPassword(newPassword)
+ await query('UPDATE admin_accounts SET password_hash = :password_hash, password_salt = :password_salt WHERE admin_id = :admin_id', { password_hash: newHash, password_salt: '', admin_id: admin.admin_id })
+ await writeLog({ admin_id: admin.admin_id, action: 'admin_change_password', detail: '管理员修改密码', ip: ctx.ip })
+ return ok({ message: 'success' })
+ })
+
router.get('/api/v1/admin/dashboard', async ctx => {
const admin = await requireAdmin(ctx)
if (!admin) return fail(1002, '未授权,请重新登录')
@@ -71,6 +90,36 @@ function register(router) {
return ok({ device_id: deviceId })
})
+ router.post('/api/v1/admin/devices/batch', async ctx => {
+ const admin = await requireAdmin(ctx)
+ if (!admin) return fail(1002, '未授权,请重新登录')
+ const deviceIds = ctx.body.device_ids
+ if (!Array.isArray(deviceIds) || deviceIds.length === 0 || deviceIds.length > 500) return fail(2001, 'device_ids must be an array with 1-500 items')
+ let successCount = 0
+ const failedIds = []
+ for (const id of deviceIds) {
+ const deviceId = String(id || '').trim()
+ if (!deviceId) { failedIds.push(id); continue }
+ try {
+ await query(
+ 'INSERT INTO devices (device_id, product_id, device_secret, device_name, firmware_version, status) VALUES (:device_id, :product_id, :device_secret, :device_name, :firmware_version, 1) ON DUPLICATE KEY UPDATE product_id = VALUES(product_id), device_secret = VALUES(device_secret), device_name = VALUES(device_name), firmware_version = VALUES(firmware_version), status = 1',
+ {
+ device_id: deviceId,
+ product_id: 'HOX_LIGHT_MASK',
+ device_secret: '',
+ device_name: '光子美容仪',
+ firmware_version: '1.0.0'
+ }
+ )
+ successCount++
+ } catch (err) {
+ failedIds.push(deviceId)
+ }
+ }
+ await writeLog({ admin_id: admin.admin_id, action: 'admin_device_batch_create', detail: '批量预生成产品码: ' + successCount + '/' + deviceIds.length, ip: ctx.ip })
+ return ok({ created: successCount, failed: failedIds })
+ })
+
router.get('/api/v1/admin/devices/:device_id', async ctx => {
const admin = await requireAdmin(ctx)
if (!admin) return fail(1002, '未授权,请重新登录')
diff --git a/server/src/routes/subscription.js b/server/src/routes/subscription.js
index 55a2199..9d369ab 100644
--- a/server/src/routes/subscription.js
+++ b/server/src/routes/subscription.js
@@ -4,6 +4,7 @@ const { requireUser, requireAdmin } = require('../lib/auth')
const { writeLog } = require('../lib/log')
const PLANS = {
+ trial: { amount: 0, days: 7 },
monthly: { amount: 99, days: 30 },
yearly: { amount: 899, days: 365 }
}
@@ -26,6 +27,18 @@ function register(router) {
return ok({ order_id: orderId, payment_params: {}, plan, amount: PLANS[plan].amount })
})
+ router.post('/api/v1/subscription/trial', async ctx => {
+ const user = await requireUser(ctx)
+ if (!user) return fail(1001, 'invalid_token')
+ const usedTrial = await one('SELECT subscription_id FROM subscriptions WHERE user_id = :user_id AND plan = \'trial\' LIMIT 1', { user_id: user.user_id })
+ if (usedTrial) return fail(2001, '已使用过试用')
+ const activeSub = await one('SELECT subscription_id FROM subscriptions WHERE user_id = :user_id AND status = 1 LIMIT 1', { user_id: user.user_id })
+ if (activeSub) return fail(2001, '已有有效订阅')
+ const orderId = 'TRIAL' + Date.now()
+ await query('INSERT INTO subscriptions (user_id, plan, status, amount, order_id, start_time, expire_time) VALUES (:user_id, \'trial\', 1, 0, :order_id, NOW(), DATE_ADD(NOW(), INTERVAL 7 DAY))', { user_id: user.user_id, order_id: orderId })
+ return ok({ status: 'active', plan: 'trial', remaining_days: 7 })
+ })
+
// Temporary: admin-only until payment integration
router.post('/api/v1/subscription/verify', async ctx => {
const admin = await requireAdmin(ctx)