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
266 行
6.1 KiB
Vue
266 行
6.1 KiB
Vue
<template>
|
||
<AdminLayout currentPage="/pages/user/index">
|
||
<view class="back-link" @click="goBack">‹ 返回用户列表</view>
|
||
|
||
<view class="page-card" v-if="user">
|
||
<view class="page-header">
|
||
<text class="page-title">👥 用户详情 - {{ user.nickname || user.user_id }}</text>
|
||
<button class="btn-primary btn-sm">发送通知</button>
|
||
</view>
|
||
|
||
<view class="user-profile">
|
||
<view class="avatar">👤</view>
|
||
<view class="profile-info">
|
||
<text class="user-name">{{ user.nickname || '-' }}</text>
|
||
<text class="user-phone">{{ user.phone || '-' }}</text>
|
||
<text class="badge badge-success">{{ subStatusText(user.subscription_status) }}</text>
|
||
</view>
|
||
</view>
|
||
|
||
<view class="stats-row">
|
||
<view class="stat-item pink-bg">
|
||
<text class="stat-value">{{ user.treatment_count || 0 }}</text>
|
||
<text class="stat-label">护理次数</text>
|
||
</view>
|
||
<view class="stat-item">
|
||
<text class="stat-value">{{ user.total_duration || '0h' }}</text>
|
||
<text class="stat-label">累计时长</text>
|
||
</view>
|
||
<view class="stat-item">
|
||
<text class="stat-value">¥{{ user.total_spent || 0 }}</text>
|
||
<text class="stat-label">累计消费</text>
|
||
</view>
|
||
</view>
|
||
</view>
|
||
|
||
<view class="page-card" v-if="user">
|
||
<view class="card-title">基本信息</view>
|
||
<view class="info-grid">
|
||
<view class="info-item">
|
||
<text class="info-label">绑定设备</text>
|
||
<text class="info-value">{{ user.devices && user.devices.length > 0 ? user.devices[0].device_name || user.devices[0].device_id : '无' }}</text>
|
||
</view>
|
||
<view class="info-item">
|
||
<text class="info-label">注册时间</text>
|
||
<text class="info-value">{{ formatDate(user.created_at) }}</text>
|
||
</view>
|
||
<view class="info-item">
|
||
<text class="info-label">订阅类型</text>
|
||
<text class="info-value">{{ subTypeText(user.subscription_type) }}</text>
|
||
</view>
|
||
<view class="info-item">
|
||
<text class="info-label">订阅到期</text>
|
||
<text class="info-value">{{ formatDate(user.subscription_expire) }}</text>
|
||
</view>
|
||
</view>
|
||
</view>
|
||
|
||
<view class="page-card" v-if="user && user.recent_treatments">
|
||
<view class="card-title">护理记录</view>
|
||
<view class="data-table">
|
||
<view class="t-header">
|
||
<view class="t-row">
|
||
<text class="t-th flex2">时间</text>
|
||
<text class="t-th flex1">时长</text>
|
||
<text class="t-th flex2">设备</text>
|
||
</view>
|
||
</view>
|
||
<view class="t-body">
|
||
<view class="t-row" v-for="(t, idx) in user.recent_treatments" :key="idx">
|
||
<text class="t-td flex2">{{ formatDate(t.started_at) }}</text>
|
||
<text class="t-td flex1">{{ Math.floor((t.total_duration_ms || 0) / 60000) }}分钟</text>
|
||
<text class="t-td flex2">{{ t.device_id || '-' }}</text>
|
||
</view>
|
||
</view>
|
||
</view>
|
||
<view class="view-all" v-if="user.treatment_count > 5">
|
||
<text class="view-all-link" @click="goRecords">查看全部{{ user.treatment_count }}条记录 ›</text>
|
||
</view>
|
||
</view>
|
||
</AdminLayout>
|
||
</template>
|
||
|
||
<script>
|
||
import { get } from '../../utils/request'
|
||
import { formatDateShort } from '../../utils/format'
|
||
import AdminLayout from '../../components/AdminLayout.vue'
|
||
|
||
export default {
|
||
components: { AdminLayout },
|
||
data() {
|
||
return {
|
||
user: null,
|
||
userId: ''
|
||
}
|
||
},
|
||
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' })
|
||
}
|
||
},
|
||
goBack() {
|
||
uni.navigateBack()
|
||
},
|
||
goRecords() {
|
||
uni.navigateTo({ url: '/pages/record/index?user_id=' + this.userId })
|
||
},
|
||
subStatusText(status) {
|
||
const map = { yearly: '年卡会员', monthly: '月卡会员', trial: '试用中', none: '未订阅' }
|
||
return map[status] || '未订阅'
|
||
},
|
||
subTypeText(type) {
|
||
const map = { yearly: '年卡', monthly: '月卡', trial: '试用' }
|
||
return map[type] || '-'
|
||
},
|
||
formatDate: formatDateShort
|
||
}
|
||
}
|
||
</script>
|
||
|
||
<style scoped>
|
||
@import '../../styles/common.css';
|
||
|
||
.back-link {
|
||
color: #E6508C;
|
||
font-size: 14px;
|
||
margin-bottom: 16px;
|
||
cursor: pointer;
|
||
}
|
||
|
||
.page-header {
|
||
display: flex;
|
||
justify-content: space-between;
|
||
align-items: center;
|
||
margin-bottom: 20px;
|
||
}
|
||
|
||
.page-title {
|
||
font-size: 18px;
|
||
font-weight: 600;
|
||
color: #333;
|
||
}
|
||
|
||
.user-profile {
|
||
display: flex;
|
||
align-items: center;
|
||
gap: 16px;
|
||
margin-bottom: 20px;
|
||
padding-bottom: 20px;
|
||
border-bottom: 1px solid #f0f0f0;
|
||
}
|
||
|
||
.avatar {
|
||
width: 64px;
|
||
height: 64px;
|
||
border-radius: 50%;
|
||
background: #f0f0f0;
|
||
display: flex;
|
||
align-items: center;
|
||
justify-content: center;
|
||
font-size: 32px;
|
||
}
|
||
|
||
.profile-info {
|
||
display: flex;
|
||
flex-direction: column;
|
||
gap: 4px;
|
||
}
|
||
|
||
.user-name {
|
||
font-size: 18px;
|
||
font-weight: 600;
|
||
color: #333;
|
||
}
|
||
|
||
.user-phone {
|
||
font-size: 14px;
|
||
color: #999;
|
||
}
|
||
|
||
.badge {
|
||
margin-top: 4px;
|
||
}
|
||
|
||
.stats-row {
|
||
display: flex;
|
||
gap: 16px;
|
||
}
|
||
|
||
.stat-item {
|
||
flex: 1;
|
||
background: #fafafa;
|
||
border-radius: 8px;
|
||
padding: 16px;
|
||
text-align: center;
|
||
}
|
||
|
||
.stat-item.pink-bg {
|
||
background: #fff0f6;
|
||
}
|
||
|
||
.stat-value {
|
||
display: block;
|
||
font-size: 24px;
|
||
font-weight: 700;
|
||
color: #333;
|
||
margin-bottom: 4px;
|
||
}
|
||
|
||
.pink-bg .stat-value {
|
||
color: #E6508C;
|
||
}
|
||
|
||
.stat-label {
|
||
font-size: 13px;
|
||
color: #999;
|
||
}
|
||
|
||
.card-title {
|
||
font-size: 16px;
|
||
font-weight: 600;
|
||
color: #333;
|
||
margin-bottom: 16px;
|
||
}
|
||
|
||
.info-grid {
|
||
display: grid;
|
||
grid-template-columns: 1fr 1fr;
|
||
gap: 16px;
|
||
}
|
||
|
||
.info-item {
|
||
display: flex;
|
||
flex-direction: column;
|
||
gap: 6px;
|
||
}
|
||
|
||
.info-label {
|
||
font-size: 13px;
|
||
color: #999;
|
||
}
|
||
|
||
.info-value {
|
||
font-size: 14px;
|
||
color: #333;
|
||
}
|
||
|
||
.view-all {
|
||
text-align: center;
|
||
margin-top: 16px;
|
||
}
|
||
|
||
.view-all-link {
|
||
color: #E6508C;
|
||
font-size: 14px;
|
||
cursor: pointer;
|
||
}
|
||
|
||
</style>
|