Server: - Block startup with default JWT secrets in production - Make subscription verify admin-only (no payment integration yet) - Add device ownership validation on command/result, event, treatment/sync - Remove admin token from request body fallback - Add pageParams boundary protection (pageSize capped at 100) - Fix COS getObjectUrl to use callback-based Promise - Add settings key whitelist matching frontend fields - Add user existence check before subscription creation - Fix firmware always returning has_update:true - Replace hardcoded trial subscription with actual DB query - Extract shared utilities (limitClause, toMysqlDate, formatDate) Miniprogram: - Replace fake PD random data with placeholder - Mark client-timer treatment completions with source field - Disable mock.js - Fix BLE listener leaks (save refs, cleanup in onUnload) - Fix ble.off clearing all listeners (pass specific callback) - Add BLE disconnect detection via onBLEConnectionStateChange - Fix subscription status type consistency (number not string) - Fix scan callback accumulation in ble.js - Fix history stats accumulation across pages - Fix subscribe-success/treatment-done hardcoded values - Fix profile subscription view logic - Replace purchase flow with admin-contact modal - Add error logging in command-sync report Admin console: - Fix AdminLayout logout (require->import, logout->clearToken) - Remove all mock data from production request.js - Replace dashboard fake data with real API calls - Replace monthly_revenue with subscription_count - Fix subscription stats fallback (|| -> ??) - Add token expiry tracking (7 days) - Unify device status map and subscription status text - Fix user page record link navigation - Fix subscription createForm.user_id type handling - Add error feedback in all empty catch blocks - Remove unused remember checkbox and uview-plus dependency - Extract common CSS to shared stylesheet (-900 lines) - Extract formatDate to shared utils/format.js - Show real admin name in layout header
170 行
5.3 KiB
Vue
170 行
5.3 KiB
Vue
<template>
|
||
<AdminLayout currentPage="/pages/user/index">
|
||
<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-default btn-sm" @click="onExport">导出用户</button>
|
||
</view>
|
||
</view>
|
||
|
||
<view class="page-card">
|
||
<view class="data-table">
|
||
<view class="t-header">
|
||
<view class="t-row">
|
||
<text class="t-th flex2">用户</text>
|
||
<text class="t-th flex2">手机号</text>
|
||
<text class="t-th flex1">绑定设备</text>
|
||
<text class="t-th flex1">护理次数</text>
|
||
<text class="t-th flex1">订阅状态</text>
|
||
<text class="t-th flex2">注册时间</text>
|
||
<text class="t-th flex1">操作</text>
|
||
</view>
|
||
</view>
|
||
<view class="t-body">
|
||
<view class="t-row" v-for="item in users" :key="item.user_id">
|
||
<text class="t-td flex2">
|
||
<view class="user-cell">
|
||
<view class="avatar-circle">👤</view>
|
||
<text>{{ item.nickname || '-' }}</text>
|
||
</view>
|
||
</text>
|
||
<text class="t-td flex2">{{ item.phone || '-' }}</text>
|
||
<text class="t-td flex1">{{ item.device_count || 0 }}</text>
|
||
<text class="t-td flex1">{{ item.treatment_count || 0 }}次</text>
|
||
<text class="t-td flex1">
|
||
<text :class="subBadge(item.subscription_status)">{{ subStatusText(item.subscription_status) }}</text>
|
||
</text>
|
||
<text class="t-td flex2">{{ formatDate(item.created_at) }}</text>
|
||
<text class="t-td flex1">
|
||
<text class="action-link" @click="onDetail(item.user_id)">详情</text>
|
||
<text class="action-link" @click="onViewRecords(item.user_id)">记录</text>
|
||
</text>
|
||
</view>
|
||
</view>
|
||
</view>
|
||
|
||
<view class="pagination">
|
||
<button class="btn-page" :disabled="page <= 1" @click="onPage(page - 1)">‹</button>
|
||
<button class="btn-page active">{{ page }}</button>
|
||
<button class="btn-page" :disabled="page >= totalPages" @click="onPage(page + 1)">›</button>
|
||
<text class="page-info">共 {{ totalPages }} 页</text>
|
||
</view>
|
||
</view>
|
||
</AdminLayout>
|
||
</template>
|
||
|
||
<script>
|
||
import { get } from '../../utils/request'
|
||
import { exportCSV } from '../../utils/export'
|
||
import { formatDateShort } from '../../utils/format'
|
||
import AdminLayout from '../../components/AdminLayout.vue'
|
||
|
||
export default {
|
||
components: { AdminLayout },
|
||
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, keyword: this.keyword })
|
||
this.users = data.records || []
|
||
this.total = data.total || 0
|
||
} catch (e) {
|
||
uni.showToast({ title: '加载失败', icon: 'none' })
|
||
}
|
||
},
|
||
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 })
|
||
},
|
||
onViewRecords(userId) {
|
||
uni.navigateTo({ url: '/pages/record/index?user_id=' + userId })
|
||
},
|
||
subStatusText(status) {
|
||
const map = { yearly: '年卡会员', monthly: '月卡会员', trial: '试用中', none: '未订阅' }
|
||
return map[status] || '未订阅'
|
||
},
|
||
subBadge(status) {
|
||
const map = {
|
||
yearly: 'badge badge-success',
|
||
monthly: 'badge badge-warning',
|
||
trial: 'badge badge-blue',
|
||
none: 'badge badge-blue'
|
||
}
|
||
return map[status] || 'badge badge-default'
|
||
},
|
||
formatDate: formatDateShort,
|
||
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' })
|
||
}
|
||
}
|
||
}
|
||
}
|
||
</script>
|
||
|
||
<style scoped>
|
||
@import '../../styles/common.css';
|
||
|
||
.user-cell {
|
||
display: flex;
|
||
align-items: center;
|
||
gap: 8px;
|
||
}
|
||
|
||
.avatar-circle {
|
||
width: 28px;
|
||
height: 28px;
|
||
border-radius: 50%;
|
||
background: #f0f0f0;
|
||
display: flex;
|
||
align-items: center;
|
||
justify-content: center;
|
||
font-size: 14px;
|
||
flex-shrink: 0;
|
||
}
|
||
|
||
.pagination {
|
||
justify-content: flex-end;
|
||
}
|
||
</style>
|