Miniprogram: - Add BLE reconnect button on home page when disconnected - Add loading states to index, profile, subscribe-plans pages - Add profile editing (avatar + nickname) with COS upload - Enable pull-to-refresh on history page - Fix auto-scan self.options → self.data inconsistency - Add console.error to silent catch blocks - Gate 'Simple Peripheral' BLE scan behind __DEV__ flag - Add production config comment to env.js Admin console: - Add empty state '暂无数据' to all 5 list views - Replace plain text plan input with select dropdown - Add type="date" to record date filters - Add production config comment Server: - CORS origin restricted in production (env CORS_ORIGIN) - DB pool size configurable via DB_POOL_SIZE env var - .env.example updated with WeChat Pay + production fields
199 行
7.1 KiB
Vue
199 行
7.1 KiB
Vue
<template>
|
||
<view>
|
||
<view class="toolbar">
|
||
<view class="header-actions">
|
||
<input
|
||
class="search-input"
|
||
v-model="keyword"
|
||
placeholder="搜索设备编号/用户..."
|
||
placeholder-class="input-placeholder"
|
||
@confirm="onSearch"
|
||
/>
|
||
<button class="btn-primary btn-sm" @click="onSearch">搜索</button>
|
||
<button class="btn-primary btn-sm" @click="onCreateDevice">预生成产品码</button>
|
||
<button class="btn-default btn-sm" @click="showBatchImport = true">批量导入</button>
|
||
<button class="btn-default btn-sm" @click="onExport">导出</button>
|
||
</view>
|
||
</view>
|
||
|
||
<DataTable
|
||
:columns="columns"
|
||
:records="records"
|
||
:page="page"
|
||
:totalPages="totalPages"
|
||
@page="onPage"
|
||
>
|
||
<template #rows>
|
||
<view class="t-row" v-for="item in records" :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.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>
|
||
</text>
|
||
<text class="t-td flex1">
|
||
<text class="action-link" @click="onDetail(item.device_id)">详情</text>
|
||
<text class="action-link" v-if="item.bound_user" @click="onUnbind(item)">解绑</text>
|
||
</text>
|
||
</view>
|
||
</template>
|
||
</DataTable>
|
||
|
||
<view class="empty-state" v-if="records.length === 0">暂无数据</view>
|
||
|
||
<ConfirmModal
|
||
:visible="showBatchImport"
|
||
title="批量导入设备"
|
||
confirmText="导入"
|
||
:loading="batchImporting"
|
||
@close="showBatchImport = false"
|
||
@confirm="onBatchImport"
|
||
>
|
||
<view class="form-group">
|
||
<text class="form-label">设备编号(每行一个)</text>
|
||
<textarea class="form-textarea" v-model="batchDeviceIds" placeholder="输入设备编号,每行一个 例如: HOX001 HOX002 HOX003" :maxlength="-1"></textarea>
|
||
</view>
|
||
<view class="batch-hint">最多 500 个设备</view>
|
||
</ConfirmModal>
|
||
</view>
|
||
</template>
|
||
|
||
<script>
|
||
import { get, post } from '../utils/request'
|
||
import { exportCSV } from '../utils/export'
|
||
import { formatDateShort } from '../utils/format'
|
||
import { listMixin } from '../utils/useList'
|
||
import DataTable from '../components/DataTable.vue'
|
||
import ConfirmModal from '../components/ConfirmModal.vue'
|
||
|
||
var STATUS_MAP = { 1: '未激活', 2: '在线', 3: '离线', 4: '故障' }
|
||
var STATUS_BADGE = { 2: 'badge badge-success', 3: 'badge badge-warning', 1: 'badge badge-blue', 4: 'badge badge-error' }
|
||
|
||
export default {
|
||
name: 'DeviceListView',
|
||
components: { DataTable, ConfirmModal },
|
||
mixins: [listMixin('/api/v1/admin/devices')],
|
||
data() {
|
||
return {
|
||
keyword: '',
|
||
statusMap: STATUS_MAP,
|
||
columns: [
|
||
{ key: 'device_id', label: '设备编号', flex: 2 },
|
||
{ key: 'product_id', label: '产品编号', flex: 2 },
|
||
{ key: 'bound_user', label: '绑定用户', flex: 2 },
|
||
{ key: 'battery', label: '电量', flex: 1 },
|
||
{ key: 'fw', label: '固件版本', flex: 1 },
|
||
{ key: 'activated_at', label: '绑定时间', flex: 2 },
|
||
{ key: 'status', label: '状态', flex: 1 },
|
||
{ key: 'actions', label: '操作', flex: 1 }
|
||
],
|
||
showBatchImport: false,
|
||
batchDeviceIds: '',
|
||
batchImporting: false
|
||
}
|
||
},
|
||
mounted() {
|
||
this.reload()
|
||
},
|
||
methods: {
|
||
reload() {
|
||
this.loadList({ keyword: this.keyword })
|
||
},
|
||
onDetail(deviceId) {
|
||
this.$emit('navigate', 'device-detail', { device_id: deviceId })
|
||
},
|
||
onCreateDevice() {
|
||
var 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.reload()
|
||
} catch (e) {
|
||
uni.showToast({ title: '创建失败', icon: 'none' })
|
||
}
|
||
}
|
||
})
|
||
},
|
||
onUnbind(item) {
|
||
var self = this
|
||
uni.showModal({
|
||
title: '确认解绑',
|
||
content: '确定要解绑设备 ' + item.device_id + ' 吗?',
|
||
success: async function (res) {
|
||
if (res.confirm) {
|
||
try {
|
||
await post('/api/v1/admin/devices/' + item.device_id + '/unbind', {})
|
||
uni.showToast({ title: '解绑成功', icon: 'success' })
|
||
self.reload()
|
||
} catch (e) {
|
||
uni.showToast({ title: '解绑失败', icon: 'none' })
|
||
}
|
||
}
|
||
}
|
||
})
|
||
},
|
||
statusBadge(status) {
|
||
return STATUS_BADGE[status] || 'badge badge-default'
|
||
},
|
||
formatDate: formatDateShort,
|
||
async onBatchImport() {
|
||
var ids = this.batchDeviceIds.split('\n').map(function (s) { return 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 {
|
||
var result = await post('/api/v1/admin/devices/batch', { device_ids: ids })
|
||
uni.showToast({ title: '导入 ' + result.created + ' 个设备', icon: 'success' })
|
||
this.showBatchImport = false
|
||
this.batchDeviceIds = ''
|
||
this.reload()
|
||
} catch (e) {
|
||
uni.showToast({ title: '导入失败', icon: 'none' })
|
||
} finally {
|
||
this.batchImporting = false
|
||
}
|
||
},
|
||
async onExport() {
|
||
try {
|
||
var data = await get('/api/v1/admin/devices', { page: 1, page_size: 9999, keyword: this.keyword })
|
||
var 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' })
|
||
}
|
||
}
|
||
}
|
||
}
|
||
</script>
|
||
|
||
<style scoped>
|
||
@import '../styles/common.css';
|
||
|
||
.batch-hint {
|
||
font-size: 12px;
|
||
color: #999;
|
||
margin-bottom: 16px;
|
||
}
|
||
</style>
|