refactor: convert admin console to SPA with dynamic component switching
- Create shell page (pages/admin/index.vue) with AdminLayout + keep-alive - Convert 9 pages to view components (views/*.vue) - AdminLayout emits navigate events instead of uni.redirectTo - Sidebar navigation no longer causes full page reload - List views cached with keep-alive, detail views re-mount fresh - Fix: add name property to 5 cached views for keep-alive matching - Fix: add navigationStyle custom to prevent double nav bar - Fix: remove duplicate mounted() in RecordView/LogView
这个提交包含在:
@@ -0,0 +1,78 @@
|
||||
<template>
|
||||
<AdminLayout :currentPage="currentView" @navigate="onNavigate">
|
||||
<keep-alive :include="keepAliveViews">
|
||||
<component :is="currentComponent" :key="viewKey" v-bind="viewProps" @navigate="onNavigate" />
|
||||
</keep-alive>
|
||||
</AdminLayout>
|
||||
</template>
|
||||
|
||||
<script>
|
||||
import AdminLayout from '../../components/AdminLayout.vue'
|
||||
import DashboardView from '../../views/DashboardView.vue'
|
||||
import DeviceListView from '../../views/DeviceListView.vue'
|
||||
import DeviceDetailView from '../../views/DeviceDetailView.vue'
|
||||
import UserListView from '../../views/UserListView.vue'
|
||||
import UserDetailView from '../../views/UserDetailView.vue'
|
||||
import SubscriptionView from '../../views/SubscriptionView.vue'
|
||||
import RecordView from '../../views/RecordView.vue'
|
||||
import LogView from '../../views/LogView.vue'
|
||||
import SettingsView from '../../views/SettingsView.vue'
|
||||
|
||||
var VIEW_MAP = {
|
||||
dashboard: DashboardView,
|
||||
device: DeviceListView,
|
||||
'device-detail': DeviceDetailView,
|
||||
user: UserListView,
|
||||
'user-detail': UserDetailView,
|
||||
subscription: SubscriptionView,
|
||||
record: RecordView,
|
||||
log: LogView,
|
||||
settings: SettingsView
|
||||
}
|
||||
|
||||
// List views that should be kept alive (preserve state when navigating to detail and back)
|
||||
var KEEP_ALIVE_VIEWS = ['DeviceListView', 'UserListView', 'SubscriptionView', 'RecordView', 'LogView']
|
||||
|
||||
export default {
|
||||
components: {
|
||||
AdminLayout,
|
||||
DashboardView,
|
||||
DeviceListView,
|
||||
DeviceDetailView,
|
||||
UserListView,
|
||||
UserDetailView,
|
||||
SubscriptionView,
|
||||
RecordView,
|
||||
LogView,
|
||||
SettingsView
|
||||
},
|
||||
data() {
|
||||
return {
|
||||
currentView: 'dashboard',
|
||||
viewProps: {},
|
||||
keepAliveViews: KEEP_ALIVE_VIEWS
|
||||
}
|
||||
},
|
||||
computed: {
|
||||
currentComponent() {
|
||||
return VIEW_MAP[this.currentView] || DashboardView
|
||||
},
|
||||
viewKey() {
|
||||
// Detail views get a unique key so they always re-mount with fresh data
|
||||
if (this.currentView === 'device-detail') {
|
||||
return 'device-detail-' + (this.viewProps.device_id || '')
|
||||
}
|
||||
if (this.currentView === 'user-detail') {
|
||||
return 'user-detail-' + (this.viewProps.user_id || '')
|
||||
}
|
||||
return this.currentView
|
||||
}
|
||||
},
|
||||
methods: {
|
||||
onNavigate(view, props) {
|
||||
this.currentView = view
|
||||
this.viewProps = props || {}
|
||||
}
|
||||
}
|
||||
}
|
||||
</script>
|
||||
@@ -1,351 +0,0 @@
|
||||
<template>
|
||||
<AdminLayout currentPage="/pages/dashboard/index">
|
||||
<view class="stat-grid">
|
||||
<view class="stat-card">
|
||||
<view class="stat-icon blue">📱</view>
|
||||
<text class="stat-value">{{ stats.device_count || 0 }}</text>
|
||||
<text class="stat-label">绑定设备数</text>
|
||||
</view>
|
||||
<view class="stat-card">
|
||||
<view class="stat-icon green">👥</view>
|
||||
<text class="stat-value">{{ stats.user_count || 0 }}</text>
|
||||
<text class="stat-label">注册用户数</text>
|
||||
</view>
|
||||
<view class="stat-card">
|
||||
<view class="stat-icon pink">💆</view>
|
||||
<text class="stat-value">{{ stats.treatment_count || 0 }}</text>
|
||||
<text class="stat-label">护理次数</text>
|
||||
</view>
|
||||
<view class="stat-card">
|
||||
<view class="stat-icon gold">💳</view>
|
||||
<text class="stat-value">{{ stats.subscription_count || 0 }}</text>
|
||||
<text class="stat-label">活跃订阅</text>
|
||||
</view>
|
||||
</view>
|
||||
|
||||
<view class="stat-grid secondary-stats" v-if="subStats.monthly_count != null">
|
||||
<view class="stat-card">
|
||||
<text class="stat-value">{{ subStats.monthly_count || 0 }}</text>
|
||||
<text class="stat-label">月卡会员</text>
|
||||
</view>
|
||||
<view class="stat-card">
|
||||
<text class="stat-value">{{ subStats.yearly_count || 0 }}</text>
|
||||
<text class="stat-label">年卡会员</text>
|
||||
</view>
|
||||
<view class="stat-card">
|
||||
<text class="stat-value">{{ subStats.trial_count || 0 }}</text>
|
||||
<text class="stat-label">试用中</text>
|
||||
</view>
|
||||
<view class="stat-card">
|
||||
<text class="stat-value">¥{{ subStats.monthly_revenue || 0 }}</text>
|
||||
<text class="stat-label">本月收入</text>
|
||||
</view>
|
||||
</view>
|
||||
|
||||
<view class="main-row">
|
||||
<view class="card table-card">
|
||||
<view class="card-header">
|
||||
<text class="card-title">实时护理数据</text>
|
||||
<text class="card-extra" @click="onNav('/pages/record/index')">查看全部 ›</text>
|
||||
</view>
|
||||
<view class="data-table">
|
||||
<view class="t-header">
|
||||
<view class="t-row">
|
||||
<text class="t-th" style="flex:2">用户</text>
|
||||
<text class="t-th" style="flex:2">设备</text>
|
||||
<text class="t-th" style="flex:1">模式</text>
|
||||
<text class="t-th" style="flex:1">时长</text>
|
||||
<text class="t-th" style="flex:2">时间</text>
|
||||
<text class="t-th" style="flex:1">状态</text>
|
||||
</view>
|
||||
</view>
|
||||
<view class="t-body">
|
||||
<view class="t-row" v-for="(item, idx) in recentTreatments" :key="idx">
|
||||
<text class="t-td" style="flex:2">{{ item.nickname || item.user_id || '-' }}</text>
|
||||
<text class="t-td" style="flex:2">{{ item.device_id || '-' }}</text>
|
||||
<text class="t-td" style="flex:1">
|
||||
<text :class="item.mode === 1 ? 'badge badge-blue' : 'badge badge-warning'">{{ item.mode === 1 ? '✨ 智能' : '🔄 普通' }}</text>
|
||||
</text>
|
||||
<text class="t-td" style="flex:1">{{ Math.floor((item.total_duration_ms || 0) / 60000) }}分</text>
|
||||
<text class="t-td" style="flex:2">{{ formatDate(item.start_time || item.started_at) }}</text>
|
||||
<text class="t-td" style="flex:1">
|
||||
<text class="badge badge-success">已完成</text>
|
||||
</text>
|
||||
</view>
|
||||
<view v-if="recentTreatments.length === 0" class="empty-state">
|
||||
<text class="empty-text">暂无护理记录</text>
|
||||
</view>
|
||||
</view>
|
||||
</view>
|
||||
</view>
|
||||
|
||||
<view class="card quick-card">
|
||||
<view class="card-title">快捷操作</view>
|
||||
<view class="quick-list">
|
||||
<view class="quick-item" @click="onNav('/pages/device/index')">
|
||||
<view class="quick-icon" style="background:#E6508C">📱</view>
|
||||
<text class="quick-label">设备管理</text>
|
||||
<text class="quick-arrow">›</text>
|
||||
</view>
|
||||
<view class="quick-item" @click="onNav('/pages/user/index')">
|
||||
<view class="quick-icon" style="background:#E6508C">👥</view>
|
||||
<text class="quick-label">用户管理</text>
|
||||
<text class="quick-arrow">›</text>
|
||||
</view>
|
||||
<view class="quick-item" @click="onNav('/pages/subscription/index')">
|
||||
<view class="quick-icon" style="background:#E6508C">💳</view>
|
||||
<text class="quick-label">订阅管理</text>
|
||||
<text class="quick-arrow">›</text>
|
||||
</view>
|
||||
<view class="quick-item" @click="onNav('/pages/record/index')">
|
||||
<view class="quick-icon" style="background:#E6508C">📋</view>
|
||||
<text class="quick-label">数据报表</text>
|
||||
<text class="quick-arrow">›</text>
|
||||
</view>
|
||||
</view>
|
||||
</view>
|
||||
</view>
|
||||
</AdminLayout>
|
||||
</template>
|
||||
|
||||
<script>
|
||||
import { get } from '../../utils/request'
|
||||
import { formatDate as formatDateUtil } from '../../utils/format'
|
||||
import AdminLayout from '../../components/AdminLayout.vue'
|
||||
|
||||
export default {
|
||||
components: { AdminLayout },
|
||||
data() {
|
||||
return {
|
||||
stats: {},
|
||||
subStats: {},
|
||||
recentTreatments: []
|
||||
}
|
||||
},
|
||||
onShow() {
|
||||
this.loadDashboard()
|
||||
},
|
||||
methods: {
|
||||
async loadDashboard() {
|
||||
try {
|
||||
const dashboard = await get('/api/v1/admin/dashboard')
|
||||
this.stats = dashboard
|
||||
if (dashboard.sub_stats) this.subStats = dashboard.sub_stats
|
||||
} catch (e) {
|
||||
uni.showToast({ title: '加载失败', icon: 'none' })
|
||||
}
|
||||
try {
|
||||
const data = await get('/api/v1/admin/records', { page: 1, page_size: 5 })
|
||||
this.recentTreatments = data.records || []
|
||||
} catch (e) {
|
||||
this.recentTreatments = []
|
||||
}
|
||||
},
|
||||
formatDate: formatDateUtil,
|
||||
onNav(path) {
|
||||
uni.redirectTo({ url: path })
|
||||
}
|
||||
}
|
||||
}
|
||||
</script>
|
||||
|
||||
<style scoped>
|
||||
.stat-grid {
|
||||
display: grid;
|
||||
grid-template-columns: repeat(4, 1fr);
|
||||
gap: 16px;
|
||||
margin-bottom: 20px;
|
||||
}
|
||||
|
||||
.stat-card {
|
||||
background: #fff;
|
||||
border-radius: 8px;
|
||||
padding: 20px;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
align-items: center;
|
||||
gap: 8px;
|
||||
text-align: center;
|
||||
}
|
||||
|
||||
.stat-icon {
|
||||
width: 48px;
|
||||
height: 48px;
|
||||
border-radius: 8px;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
font-size: 24px;
|
||||
}
|
||||
|
||||
.stat-icon.blue {
|
||||
background: #e6f7ff;
|
||||
}
|
||||
|
||||
.stat-icon.green {
|
||||
background: #f6ffed;
|
||||
}
|
||||
|
||||
.stat-icon.pink {
|
||||
background: #fff0f6;
|
||||
}
|
||||
|
||||
.stat-icon.gold {
|
||||
background: #fff7e6;
|
||||
}
|
||||
|
||||
.stat-value {
|
||||
display: block;
|
||||
font-size: 24px;
|
||||
font-weight: 700;
|
||||
color: #333;
|
||||
}
|
||||
|
||||
.stat-label {
|
||||
font-size: 14px;
|
||||
color: #999;
|
||||
}
|
||||
|
||||
.main-row {
|
||||
display: flex;
|
||||
gap: 16px;
|
||||
}
|
||||
|
||||
.card {
|
||||
background: #fff;
|
||||
border-radius: 8px;
|
||||
padding: 20px;
|
||||
}
|
||||
|
||||
.table-card {
|
||||
flex: 1;
|
||||
}
|
||||
|
||||
.quick-card {
|
||||
width: 260px;
|
||||
flex-shrink: 0;
|
||||
}
|
||||
|
||||
.card-header {
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
align-items: center;
|
||||
margin-bottom: 16px;
|
||||
}
|
||||
|
||||
.card-title {
|
||||
font-size: 16px;
|
||||
font-weight: 600;
|
||||
color: #333;
|
||||
margin-bottom: 16px;
|
||||
}
|
||||
|
||||
.card-header .card-title {
|
||||
margin-bottom: 0;
|
||||
}
|
||||
|
||||
.card-extra {
|
||||
font-size: 14px;
|
||||
color: #E6508C;
|
||||
cursor: pointer;
|
||||
}
|
||||
|
||||
.data-table {
|
||||
width: 100%;
|
||||
}
|
||||
|
||||
.t-header {
|
||||
background: #fafafa;
|
||||
}
|
||||
|
||||
.t-row {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
padding: 12px 0;
|
||||
border-bottom: 1px solid #f0f0f0;
|
||||
}
|
||||
|
||||
.t-th {
|
||||
font-size: 14px;
|
||||
color: #666;
|
||||
font-weight: 500;
|
||||
}
|
||||
|
||||
.t-td {
|
||||
font-size: 14px;
|
||||
color: #333;
|
||||
}
|
||||
|
||||
.badge {
|
||||
display: inline-block;
|
||||
padding: 2px 8px;
|
||||
border-radius: 4px;
|
||||
font-size: 12px;
|
||||
}
|
||||
|
||||
.badge-success {
|
||||
background: #f6ffed;
|
||||
color: #52c41a;
|
||||
}
|
||||
|
||||
.badge-blue {
|
||||
background: #e6f7ff;
|
||||
color: #1890ff;
|
||||
}
|
||||
|
||||
.quick-list {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 12px;
|
||||
}
|
||||
|
||||
.quick-item {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 12px;
|
||||
padding: 12px;
|
||||
border-radius: 8px;
|
||||
background: #fafafa;
|
||||
cursor: pointer;
|
||||
}
|
||||
|
||||
.quick-item:hover {
|
||||
background: #f0f0f0;
|
||||
}
|
||||
|
||||
.quick-icon {
|
||||
width: 40px;
|
||||
height: 40px;
|
||||
border-radius: 8px;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
font-size: 20px;
|
||||
color: #fff;
|
||||
}
|
||||
|
||||
.quick-label {
|
||||
flex: 1;
|
||||
font-size: 14px;
|
||||
color: #333;
|
||||
}
|
||||
|
||||
.quick-arrow {
|
||||
font-size: 18px;
|
||||
color: #999;
|
||||
}
|
||||
|
||||
.empty-state {
|
||||
padding: 24px 0;
|
||||
text-align: center;
|
||||
}
|
||||
|
||||
.empty-text {
|
||||
font-size: 14px;
|
||||
color: #999;
|
||||
}
|
||||
|
||||
.secondary-stats {
|
||||
margin-bottom: 20px;
|
||||
}
|
||||
</style>
|
||||
@@ -1,267 +0,0 @@
|
||||
<template>
|
||||
<AdminLayout currentPage="/pages/device/index">
|
||||
<view class="back-link" @click="goBack">‹ 返回设备列表</view>
|
||||
|
||||
<view class="page-card">
|
||||
<view class="page-header">
|
||||
<text class="page-title">📱 设备详情 - {{ device ? device.device_id : '' }}</text>
|
||||
<button class="btn-danger btn-sm" v-if="device && device.bound_user" @click="onUnbind">解绑设备</button>
|
||||
</view>
|
||||
|
||||
<view class="info-grid" v-if="device">
|
||||
<view class="info-item">
|
||||
<text class="info-label">设备编号</text>
|
||||
<text class="info-value">{{ device.device_id }}</text>
|
||||
</view>
|
||||
<view class="info-item">
|
||||
<text class="info-label">状态</text>
|
||||
<text class="info-value"><text class="badge badge-success">{{ statusMap[device.status] || '未知' }}</text></text>
|
||||
</view>
|
||||
<view class="info-item">
|
||||
<text class="info-label">绑定用户</text>
|
||||
<text class="info-value">{{ device.bound_user || '未绑定' }}</text>
|
||||
</view>
|
||||
<view class="info-item">
|
||||
<text class="info-label">绑定时间</text>
|
||||
<text class="info-value">{{ device.activated_at || '-' }}</text>
|
||||
</view>
|
||||
<view class="info-item">
|
||||
<text class="info-label">固件版本</text>
|
||||
<view class="info-value-row">
|
||||
<text class="info-value">{{ device.firmware_version || '-' }}</text>
|
||||
<button class="btn-default btn-xs" @click="onCheckUpdate">检查更新</button>
|
||||
</view>
|
||||
</view>
|
||||
<view class="info-item">
|
||||
<text class="info-label">电池电量</text>
|
||||
<text class="info-value">{{ device.battery != null ? device.battery + '%' : '-' }}</text>
|
||||
</view>
|
||||
<view class="info-item">
|
||||
<text class="info-label">累计使用</text>
|
||||
<text class="info-value">{{ device.total_usage || '-' }}</text>
|
||||
</view>
|
||||
<view class="info-item">
|
||||
<text class="info-label">最后在线</text>
|
||||
<text class="info-value">{{ device.last_online_at || '-' }}</text>
|
||||
</view>
|
||||
</view>
|
||||
</view>
|
||||
|
||||
<view class="page-card" v-if="device && device.binding_history">
|
||||
<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 flex2">绑定时间</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="(b, idx) in device.binding_history" :key="idx">
|
||||
<text class="t-td flex2">{{ b.nickname || '-' }}</text>
|
||||
<text class="t-td flex2">{{ formatDate(b.bind_time) }}</text>
|
||||
<text class="t-td flex2">{{ b.unbind_time ? formatDate(b.unbind_time) : '-' }}</text>
|
||||
<text class="t-td flex1">
|
||||
<text :class="b.bind_status === 1 ? 'badge badge-success' : 'badge badge-default'">
|
||||
{{ b.bind_status === 1 ? '有效' : '已解绑' }}
|
||||
</text>
|
||||
</text>
|
||||
</view>
|
||||
</view>
|
||||
</view>
|
||||
</view>
|
||||
|
||||
<view class="page-card" v-if="device && device.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 device.recent_treatments" :key="idx">
|
||||
<text class="t-td flex2">{{ t.nickname || '-' }}</text>
|
||||
<text class="t-td flex1">{{ Math.floor((t.total_duration_ms || 0) / 60000) }}分钟</text>
|
||||
<text class="t-td flex2">{{ formatDate(t.started_at) }}</text>
|
||||
</view>
|
||||
</view>
|
||||
</view>
|
||||
</view>
|
||||
|
||||
<view class="action-row">
|
||||
<button class="btn-primary" @click="onCommand(4)">查询状态</button>
|
||||
<button class="btn-default" @click="onViewLogs">查看完整日志</button>
|
||||
</view>
|
||||
</AdminLayout>
|
||||
</template>
|
||||
|
||||
<script>
|
||||
import { get, post } from '../../utils/request'
|
||||
import { formatDate as formatDateUtil } from '../../utils/format'
|
||||
import AdminLayout from '../../components/AdminLayout.vue'
|
||||
|
||||
export default {
|
||||
components: { AdminLayout },
|
||||
data() {
|
||||
return {
|
||||
device: null,
|
||||
deviceId: '',
|
||||
statusMap: { 1: '未激活', 2: '在线', 3: '离线', 4: '故障' }
|
||||
}
|
||||
},
|
||||
onLoad(options) {
|
||||
this.deviceId = options.device_id || ''
|
||||
this.loadDevice()
|
||||
},
|
||||
methods: {
|
||||
async loadDevice() {
|
||||
try {
|
||||
this.device = await get('/api/v1/admin/devices/' + this.deviceId)
|
||||
} catch (e) {
|
||||
uni.showToast({ title: '加载失败', icon: 'none' })
|
||||
}
|
||||
},
|
||||
goBack() {
|
||||
uni.navigateBack()
|
||||
},
|
||||
async onCommand(type) {
|
||||
try {
|
||||
await post('/api/v1/admin/devices/' + this.deviceId + '/command', { opcode: type })
|
||||
uni.showToast({ title: '指令已发送', icon: 'success' })
|
||||
} catch (e) {
|
||||
uni.showToast({ title: '发送失败', icon: 'none' })
|
||||
}
|
||||
},
|
||||
onUnbind() {
|
||||
uni.showModal({
|
||||
title: '确认解绑',
|
||||
content: '确定要解绑该设备吗?',
|
||||
success: async (res) => {
|
||||
if (res.confirm) {
|
||||
try {
|
||||
await post('/api/v1/admin/devices/' + this.deviceId + '/unbind', {})
|
||||
uni.showToast({ title: '已解绑', icon: 'success' })
|
||||
this.loadDevice()
|
||||
} catch (e) {
|
||||
uni.showToast({ title: '解绑失败', icon: 'none' })
|
||||
}
|
||||
}
|
||||
}
|
||||
})
|
||||
},
|
||||
onViewLogs() {
|
||||
uni.redirectTo({ url: '/pages/log/index?device_id=' + this.deviceId })
|
||||
},
|
||||
onCheckUpdate() {
|
||||
uni.showToast({ title: '正在检查更新...', icon: 'none' })
|
||||
},
|
||||
formatDate: formatDateUtil
|
||||
}
|
||||
}
|
||||
</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;
|
||||
}
|
||||
|
||||
.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;
|
||||
}
|
||||
|
||||
.info-value-row {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 8px;
|
||||
}
|
||||
|
||||
.card-title {
|
||||
font-size: 16px;
|
||||
font-weight: 600;
|
||||
color: #333;
|
||||
margin-bottom: 16px;
|
||||
}
|
||||
|
||||
.btn-primary {
|
||||
padding: 0 20px;
|
||||
height: 36px;
|
||||
font-size: 14px;
|
||||
line-height: 36px;
|
||||
}
|
||||
|
||||
.btn-default {
|
||||
padding: 0 20px;
|
||||
height: 36px;
|
||||
font-size: 14px;
|
||||
line-height: 36px;
|
||||
}
|
||||
|
||||
.btn-danger {
|
||||
background: #ff4d4f;
|
||||
color: #fff;
|
||||
border: none;
|
||||
border-radius: 6px;
|
||||
cursor: pointer;
|
||||
}
|
||||
|
||||
.btn-sm {
|
||||
height: 32px;
|
||||
padding: 0 16px;
|
||||
font-size: 14px;
|
||||
line-height: 32px;
|
||||
}
|
||||
|
||||
.btn-xs {
|
||||
height: 24px;
|
||||
padding: 0 8px;
|
||||
font-size: 12px;
|
||||
line-height: 24px;
|
||||
}
|
||||
|
||||
.action-row {
|
||||
display: flex;
|
||||
gap: 12px;
|
||||
margin-top: 8px;
|
||||
}
|
||||
</style>
|
||||
@@ -1,196 +0,0 @@
|
||||
<template>
|
||||
<AdminLayout currentPage="/pages/device/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-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>
|
||||
|
||||
<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>
|
||||
</AdminLayout>
|
||||
</template>
|
||||
|
||||
<script>
|
||||
import { get, post } from '../../utils/request'
|
||||
import { exportCSV } from '../../utils/export'
|
||||
import { formatDateShort } from '../../utils/format'
|
||||
import { listMixin } from '../../utils/useList'
|
||||
import AdminLayout from '../../components/AdminLayout.vue'
|
||||
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 {
|
||||
components: { AdminLayout, 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
|
||||
}
|
||||
},
|
||||
onShow() {
|
||||
this.reload()
|
||||
},
|
||||
methods: {
|
||||
reload() {
|
||||
this.loadList({ keyword: this.keyword })
|
||||
},
|
||||
onDetail(deviceId) {
|
||||
uni.navigateTo({ url: '/pages/device-detail/index?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>
|
||||
@@ -1,196 +0,0 @@
|
||||
<template>
|
||||
<AdminLayout currentPage="/pages/log/index">
|
||||
<view class="toolbar">
|
||||
<view class="header-actions">
|
||||
<input
|
||||
class="search-input"
|
||||
v-model="filters.type"
|
||||
placeholder="搜索操作类型"
|
||||
placeholder-class="input-placeholder"
|
||||
/>
|
||||
<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="log-list">
|
||||
<view class="log-item" v-for="item in logs" :key="item.log_id">
|
||||
<text class="log-time">{{ formatDate(item.created_at) }}</text>
|
||||
<view class="log-content">
|
||||
<text :class="actorBadge(item)">{{ actorLabel(item) }}</text>
|
||||
<text class="log-text">
|
||||
<text class="log-action">{{ actionLabel(item.action) }}</text>
|
||||
<text class="log-detail" v-if="item.detail"> — {{ item.detail }}</text>
|
||||
</text>
|
||||
<text class="log-meta" v-if="item.ip">IP: {{ item.ip }}</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 { formatDate as formatDateUtil } from '../../utils/format'
|
||||
import AdminLayout from '../../components/AdminLayout.vue'
|
||||
|
||||
export default {
|
||||
components: { AdminLayout },
|
||||
data() {
|
||||
return {
|
||||
logs: [],
|
||||
total: 0,
|
||||
page: 1,
|
||||
pageSize: 20,
|
||||
filters: { type: '', device_id: '' }
|
||||
}
|
||||
},
|
||||
computed: {
|
||||
totalPages() {
|
||||
return Math.ceil(this.total / this.pageSize) || 1
|
||||
}
|
||||
},
|
||||
onLoad(options) {
|
||||
if (options && options.device_id) {
|
||||
this.filters.device_id = options.device_id
|
||||
}
|
||||
},
|
||||
onShow() {
|
||||
this.loadLogs()
|
||||
},
|
||||
methods: {
|
||||
async loadLogs() {
|
||||
try {
|
||||
var params = { page: this.page, page_size: this.pageSize }
|
||||
if (this.filters.type) params.type = this.filters.type
|
||||
if (this.filters.device_id) params.device_id = this.filters.device_id
|
||||
const data = await get('/api/v1/admin/logs', params)
|
||||
this.logs = data.records || []
|
||||
this.total = data.total || 0
|
||||
} catch (e) {
|
||||
uni.showToast({ title: '加载失败', icon: 'none' })
|
||||
}
|
||||
},
|
||||
onSearch() {
|
||||
this.page = 1
|
||||
this.loadLogs()
|
||||
},
|
||||
onPage(p) {
|
||||
this.page = p
|
||||
this.loadLogs()
|
||||
},
|
||||
actorLabel(item) {
|
||||
if (item.admin_id) return '管理员 #' + item.admin_id
|
||||
if (item.user_id) return '用户 #' + item.user_id
|
||||
return '系统'
|
||||
},
|
||||
actorBadge(item) {
|
||||
if (item.admin_id) return 'badge badge-pink'
|
||||
if (item.user_id) return 'badge badge-green'
|
||||
return 'badge badge-blue'
|
||||
},
|
||||
actionLabel(action) {
|
||||
var map = {
|
||||
admin_login: '管理员登录', admin_device_create: '创建设备', admin_device_unbind: '后台解绑',
|
||||
admin_device_command: '下发指令', user_register: '用户注册', user_login: '用户登录',
|
||||
device_bind: '设备绑定', device_unbind: '设备解绑', treatment_sync: '护理记录同步',
|
||||
subscription_verify: '订阅核销'
|
||||
}
|
||||
return map[action] || action
|
||||
},
|
||||
formatDate: formatDateUtil,
|
||||
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.admin_id ? '管理员#' + r.admin_id : r.user_id ? '用户#' + r.user_id : '系统']
|
||||
})
|
||||
)
|
||||
uni.showToast({ title: '导出成功', icon: 'success' })
|
||||
} catch (e) {
|
||||
uni.showToast({ title: '导出失败', icon: 'none' })
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
</script>
|
||||
|
||||
<style scoped>
|
||||
@import '../../styles/common.css';
|
||||
|
||||
.log-list {
|
||||
margin-top: 8px;
|
||||
}
|
||||
|
||||
.log-item {
|
||||
display: flex;
|
||||
align-items: flex-start;
|
||||
padding: 12px 0;
|
||||
border-bottom: 1px solid #f0f0f0;
|
||||
}
|
||||
|
||||
.log-time {
|
||||
width: 140px;
|
||||
flex-shrink: 0;
|
||||
font-size: 13px;
|
||||
color: #999;
|
||||
padding-top: 2px;
|
||||
}
|
||||
|
||||
.log-content {
|
||||
flex: 1;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 4px;
|
||||
}
|
||||
|
||||
.badge {
|
||||
width: fit-content;
|
||||
}
|
||||
|
||||
.badge-pink {
|
||||
background: #fff0f6;
|
||||
color: #E6508C;
|
||||
}
|
||||
|
||||
.badge-green {
|
||||
background: #f6ffed;
|
||||
color: #52c41a;
|
||||
}
|
||||
|
||||
.log-text {
|
||||
font-size: 14px;
|
||||
line-height: 22px;
|
||||
color: #666;
|
||||
}
|
||||
|
||||
.log-action {
|
||||
color: #333;
|
||||
font-weight: 500;
|
||||
}
|
||||
|
||||
.log-detail {
|
||||
color: #999;
|
||||
}
|
||||
|
||||
.log-meta {
|
||||
font-size: 12px;
|
||||
color: #bbb;
|
||||
margin-top: 2px;
|
||||
}
|
||||
</style>
|
||||
@@ -57,7 +57,7 @@ export default {
|
||||
try {
|
||||
const userStore = useUserStore()
|
||||
await userStore.login(this.form)
|
||||
uni.reLaunch({ url: '/pages/dashboard/index' })
|
||||
uni.reLaunch({ url: '/pages/admin/index' })
|
||||
} catch (e) {
|
||||
uni.showToast({ title: e.message || '登录失败', icon: 'none' })
|
||||
} finally {
|
||||
|
||||
@@ -1,186 +0,0 @@
|
||||
<template>
|
||||
<AdminLayout currentPage="/pages/record/index">
|
||||
<view class="toolbar">
|
||||
<view class="header-actions">
|
||||
<input class="search-input date-input" v-model="dateFrom" placeholder="开始日期" />
|
||||
<text class="date-sep">~</text>
|
||||
<input class="search-input date-input" v-model="dateTo" placeholder="结束日期" />
|
||||
<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 flex1">记录ID</text>
|
||||
<text class="t-th flex1">用户</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 records" :key="item.record_id">
|
||||
<text class="t-td flex1">{{ item.record_id ? String(item.record_id) : '-' }}</text>
|
||||
<text class="t-td flex1">{{ item.nickname || item.user_id || '-' }}</text>
|
||||
<text class="t-td flex2">{{ item.device_id || '-' }}</text>
|
||||
<text class="t-td flex1">
|
||||
<text :class="item.mode === 1 ? 'badge badge-blue' : 'badge badge-warning'">
|
||||
{{ item.mode === 1 ? '✨ 智能模式' : '🔄 普通模式' }}
|
||||
</text>
|
||||
</text>
|
||||
<text class="t-td flex1">{{ formatRegions(item.regions) }}</text>
|
||||
<text class="t-td flex1">{{ Math.floor((item.total_duration_ms || 0) / 60000) }}分</text>
|
||||
<text class="t-td flex2">{{ formatDate(item.start_time) }}</text>
|
||||
<text class="t-td flex1">
|
||||
<text class="action-link" @click="onDetail(item)">详情</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 { formatDate as formatDateUtil } from '../../utils/format'
|
||||
import AdminLayout from '../../components/AdminLayout.vue'
|
||||
|
||||
var REGION_MAP = {
|
||||
1: '左脸', 2: '右脸', 4: '额头', 8: '下巴', 16: '鼻', 32: '左眼', 64: '右眼'
|
||||
}
|
||||
|
||||
export default {
|
||||
components: { AdminLayout },
|
||||
data() {
|
||||
return {
|
||||
records: [],
|
||||
total: 0,
|
||||
page: 1,
|
||||
pageSize: 20,
|
||||
keyword: '',
|
||||
dateFrom: '',
|
||||
dateTo: ''
|
||||
}
|
||||
},
|
||||
computed: {
|
||||
totalPages() {
|
||||
return Math.ceil(this.total / this.pageSize) || 1
|
||||
}
|
||||
},
|
||||
onShow() {
|
||||
this.loadRecords()
|
||||
},
|
||||
methods: {
|
||||
async loadRecords() {
|
||||
try {
|
||||
var params = {
|
||||
type: 'treatment',
|
||||
page: this.page,
|
||||
page_size: this.pageSize,
|
||||
keyword: this.keyword,
|
||||
date_from: this.dateFrom,
|
||||
date_to: this.dateTo
|
||||
}
|
||||
const data = await get('/api/v1/admin/records', params)
|
||||
this.records = data.records || []
|
||||
this.total = data.total || 0
|
||||
} catch (e) {
|
||||
uni.showToast({ title: '加载失败', icon: 'none' })
|
||||
}
|
||||
},
|
||||
onSearch() {
|
||||
this.page = 1
|
||||
this.loadRecords()
|
||||
},
|
||||
onPage(p) {
|
||||
this.page = p
|
||||
this.loadRecords()
|
||||
},
|
||||
formatRegions(mask) {
|
||||
if (!mask) return '-'
|
||||
var names = []
|
||||
var bits = [1, 2, 4, 8, 16, 32, 64]
|
||||
for (var i = 0; i < bits.length; i++) {
|
||||
if (mask & bits[i]) names.push(REGION_MAP[bits[i]])
|
||||
}
|
||||
return names.join(',') || '-'
|
||||
},
|
||||
onDetail(item) {
|
||||
var regions = this.formatRegions(item.regions)
|
||||
var mode = item.mode === 1 ? '智能模式' : '普通模式'
|
||||
var duration = Math.floor((item.total_duration_ms || 0) / 60000)
|
||||
var content = '用户: ' + (item.nickname || item.user_id || '-') +
|
||||
'\n设备: ' + (item.device_id || '-') +
|
||||
'\n模式: ' + mode +
|
||||
'\n区域: ' + regions +
|
||||
'\n时长: ' + duration + '分钟' +
|
||||
'\n时间: ' + this.formatDate(item.start_time)
|
||||
uni.showModal({
|
||||
title: '护理记录详情 #' + (item.record_id || ''),
|
||||
content: content,
|
||||
showCancel: false
|
||||
})
|
||||
},
|
||||
formatDate: formatDateUtil,
|
||||
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 === 1 ? '智能模式' : '普通模式', 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' })
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
</script>
|
||||
|
||||
<style scoped>
|
||||
@import '../../styles/common.css';
|
||||
|
||||
.search-input {
|
||||
width: 160px;
|
||||
}
|
||||
|
||||
.date-input {
|
||||
width: 120px;
|
||||
}
|
||||
|
||||
.date-sep {
|
||||
color: #999;
|
||||
font-size: 14px;
|
||||
}
|
||||
</style>
|
||||
@@ -1,395 +0,0 @@
|
||||
<template>
|
||||
<AdminLayout currentPage="/pages/settings/index">
|
||||
<view class="page-card">
|
||||
<view class="section-card">
|
||||
<view class="section-title">修改密码</view>
|
||||
<view class="form-grid">
|
||||
<view class="form-row">
|
||||
<text class="form-label">当前密码</text>
|
||||
<input class="form-input" v-model="passwordForm.old_password" type="password" placeholder="请输入当前密码" />
|
||||
</view>
|
||||
<view class="form-row">
|
||||
<text class="form-label">新密码</text>
|
||||
<input class="form-input" v-model="passwordForm.new_password" type="password" placeholder="请输入新密码(≥6位)" />
|
||||
</view>
|
||||
<view class="form-row">
|
||||
<text class="form-label">确认新密码</text>
|
||||
<input class="form-input" v-model="passwordForm.confirm_password" type="password" placeholder="请再次输入新密码" />
|
||||
</view>
|
||||
<view class="form-row">
|
||||
<text class="form-label"></text>
|
||||
<button class="btn-primary btn-sm" @click="onChangePassword">修改密码</button>
|
||||
</view>
|
||||
</view>
|
||||
</view>
|
||||
|
||||
<view class="section-card">
|
||||
<view class="section-title">基础配置</view>
|
||||
<view class="form-grid">
|
||||
<view class="form-row">
|
||||
<text class="form-label">系统名称</text>
|
||||
<input class="form-input" v-model="settings.system_name" placeholder="系统名称" />
|
||||
</view>
|
||||
<view class="form-row">
|
||||
<text class="form-label">管理员邮箱</text>
|
||||
<input class="form-input" v-model="settings.admin_email" placeholder="管理员邮箱" />
|
||||
</view>
|
||||
<view class="form-row">
|
||||
<text class="form-label">系统时区</text>
|
||||
<view class="select-wrap">
|
||||
<picker :range="timezoneOptions" @change="onTimezoneChange">
|
||||
<view class="form-input picker-input">
|
||||
<text>{{ settings.timezone || '请选择' }}</text>
|
||||
<text class="picker-arrow">›</text>
|
||||
</view>
|
||||
</picker>
|
||||
</view>
|
||||
</view>
|
||||
</view>
|
||||
</view>
|
||||
|
||||
<view class="section-card">
|
||||
<view class="section-title">订阅配置</view>
|
||||
<view class="form-grid">
|
||||
<view class="form-row">
|
||||
<text class="form-label">月卡价格</text>
|
||||
<view class="input-suffix">
|
||||
<input class="form-input" v-model="settings.monthly_price" type="number" />
|
||||
<text class="suffix">元/月</text>
|
||||
</view>
|
||||
</view>
|
||||
<view class="form-row">
|
||||
<text class="form-label">年卡价格</text>
|
||||
<view class="input-suffix">
|
||||
<input class="form-input" v-model="settings.yearly_price" type="number" />
|
||||
<text class="suffix">元/年</text>
|
||||
</view>
|
||||
</view>
|
||||
<view class="form-row">
|
||||
<text class="form-label">试用时长</text>
|
||||
<view class="input-suffix">
|
||||
<input class="form-input" v-model="settings.trial_days" type="number" />
|
||||
<text class="suffix">天</text>
|
||||
</view>
|
||||
</view>
|
||||
</view>
|
||||
</view>
|
||||
|
||||
<view class="section-card">
|
||||
<view class="section-title">功能开关</view>
|
||||
<view class="toggle-list">
|
||||
<view class="toggle-item">
|
||||
<view class="toggle-info">
|
||||
<text class="toggle-label">新用户注册</text>
|
||||
<text class="toggle-desc">允许新用户注册账号</text>
|
||||
</view>
|
||||
<view class="toggle-switch" :class="{ on: settings.enable_register }" @click="settings.enable_register = !settings.enable_register">
|
||||
<view class="toggle-dot"></view>
|
||||
</view>
|
||||
</view>
|
||||
<view class="toggle-item">
|
||||
<view class="toggle-info">
|
||||
<text class="toggle-label">设备绑定</text>
|
||||
<text class="toggle-desc">允许用户绑定设备</text>
|
||||
</view>
|
||||
<view class="toggle-switch" :class="{ on: settings.enable_binding }" @click="settings.enable_binding = !settings.enable_binding">
|
||||
<view class="toggle-dot"></view>
|
||||
</view>
|
||||
</view>
|
||||
<view class="toggle-item">
|
||||
<view class="toggle-info">
|
||||
<text class="toggle-label">免费普通模式</text>
|
||||
<text class="toggle-desc">未订阅用户可使用普通模式</text>
|
||||
</view>
|
||||
<view class="toggle-switch" :class="{ on: settings.enable_free_mode }" @click="settings.enable_free_mode = !settings.enable_free_mode">
|
||||
<view class="toggle-dot"></view>
|
||||
</view>
|
||||
</view>
|
||||
<view class="toggle-item">
|
||||
<view class="toggle-info">
|
||||
<text class="toggle-label">维护模式</text>
|
||||
<text class="toggle-desc">开启后系统进入维护状态</text>
|
||||
</view>
|
||||
<view class="toggle-switch" :class="{ on: settings.maintenance_mode }" @click="settings.maintenance_mode = !settings.maintenance_mode">
|
||||
<view class="toggle-dot"></view>
|
||||
</view>
|
||||
</view>
|
||||
</view>
|
||||
</view>
|
||||
</view>
|
||||
|
||||
<view class="save-bar">
|
||||
<button class="btn-primary" @click="onSave">保存设置</button>
|
||||
<button class="btn-default" @click="onReset">重置</button>
|
||||
</view>
|
||||
</AdminLayout>
|
||||
</template>
|
||||
|
||||
<script>
|
||||
import { get, post } from '../../utils/request'
|
||||
import AdminLayout from '../../components/AdminLayout.vue'
|
||||
|
||||
export default {
|
||||
components: { AdminLayout },
|
||||
data() {
|
||||
return {
|
||||
settings: {
|
||||
system_name: '',
|
||||
admin_email: '',
|
||||
timezone: 'Asia/Shanghai',
|
||||
monthly_price: '99',
|
||||
yearly_price: '899',
|
||||
trial_days: '7',
|
||||
enable_register: true,
|
||||
enable_binding: true,
|
||||
enable_free_mode: true,
|
||||
maintenance_mode: false
|
||||
},
|
||||
timezoneOptions: ['Asia/Shanghai', 'Asia/Tokyo', 'America/New_York', 'Europe/London'],
|
||||
passwordForm: { old_password: '', new_password: '', confirm_password: '' }
|
||||
}
|
||||
},
|
||||
onShow() {
|
||||
this.loadSettings()
|
||||
},
|
||||
methods: {
|
||||
async loadSettings() {
|
||||
try {
|
||||
const data = await get('/api/v1/admin/settings')
|
||||
if (data) {
|
||||
Object.assign(this.settings, data)
|
||||
}
|
||||
} catch (e) {
|
||||
uni.showToast({ title: '加载失败', icon: 'none' })
|
||||
}
|
||||
},
|
||||
async onSave() {
|
||||
try {
|
||||
await post('/api/v1/admin/settings', this.settings)
|
||||
uni.showToast({ title: '保存成功', icon: 'success' })
|
||||
} catch (e) {
|
||||
uni.showToast({ title: '保存失败', icon: 'none' })
|
||||
}
|
||||
},
|
||||
onReset() {
|
||||
this.loadSettings()
|
||||
},
|
||||
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' })
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
</script>
|
||||
|
||||
<style scoped>
|
||||
.page-card {
|
||||
background: #fff;
|
||||
border-radius: 8px;
|
||||
padding: 20px;
|
||||
margin-bottom: 16px;
|
||||
}
|
||||
|
||||
.section-card {
|
||||
padding: 16px 0;
|
||||
border-bottom: 1px solid #f0f0f0;
|
||||
}
|
||||
|
||||
.section-card:last-of-type {
|
||||
border-bottom: none;
|
||||
}
|
||||
|
||||
.section-title {
|
||||
font-size: 15px;
|
||||
font-weight: 600;
|
||||
color: #333;
|
||||
margin-bottom: 16px;
|
||||
}
|
||||
|
||||
.form-grid {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 12px;
|
||||
}
|
||||
|
||||
.form-row {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 12px;
|
||||
}
|
||||
|
||||
.form-label {
|
||||
width: 140px;
|
||||
flex-shrink: 0;
|
||||
font-size: 14px;
|
||||
color: #333;
|
||||
text-align: right;
|
||||
}
|
||||
|
||||
.form-input {
|
||||
height: 36px;
|
||||
border: 1px solid #d9d9d9;
|
||||
border-radius: 6px;
|
||||
padding: 0 12px;
|
||||
font-size: 14px;
|
||||
box-sizing: border-box;
|
||||
color: #333;
|
||||
flex: 1;
|
||||
}
|
||||
|
||||
.input-suffix {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 8px;
|
||||
flex: 1;
|
||||
}
|
||||
|
||||
.input-suffix .form-input {
|
||||
flex: 1;
|
||||
}
|
||||
|
||||
.suffix {
|
||||
font-size: 14px;
|
||||
color: #999;
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
.select-wrap {
|
||||
position: relative;
|
||||
flex: 1;
|
||||
}
|
||||
|
||||
.picker-input {
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
align-items: center;
|
||||
line-height: 36px;
|
||||
}
|
||||
|
||||
.picker-arrow {
|
||||
color: #999;
|
||||
font-size: 16px;
|
||||
}
|
||||
|
||||
.toggle-list {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 12px;
|
||||
}
|
||||
|
||||
.toggle-item {
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
align-items: center;
|
||||
padding: 12px 0;
|
||||
}
|
||||
|
||||
.toggle-info {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 4px;
|
||||
}
|
||||
|
||||
.toggle-label {
|
||||
font-size: 14px;
|
||||
font-weight: 500;
|
||||
color: #333;
|
||||
}
|
||||
|
||||
.toggle-desc {
|
||||
font-size: 12px;
|
||||
color: #999;
|
||||
}
|
||||
|
||||
.toggle-switch {
|
||||
width: 44px;
|
||||
height: 24px;
|
||||
border-radius: 12px;
|
||||
background: #d9d9d9;
|
||||
position: relative;
|
||||
cursor: pointer;
|
||||
transition: background 0.3s;
|
||||
}
|
||||
|
||||
.toggle-switch.on {
|
||||
background: #52c41a;
|
||||
}
|
||||
|
||||
.toggle-dot {
|
||||
width: 20px;
|
||||
height: 20px;
|
||||
border-radius: 50%;
|
||||
background: #fff;
|
||||
position: absolute;
|
||||
top: 2px;
|
||||
left: 2px;
|
||||
transition: left 0.3s;
|
||||
}
|
||||
|
||||
.toggle-switch.on .toggle-dot {
|
||||
left: 22px;
|
||||
}
|
||||
|
||||
.save-bar {
|
||||
background: #fff;
|
||||
border-radius: 8px;
|
||||
padding: 16px 20px;
|
||||
display: flex;
|
||||
justify-content: flex-end;
|
||||
gap: 12px;
|
||||
}
|
||||
|
||||
.btn-primary {
|
||||
background: #E6508C;
|
||||
color: #fff;
|
||||
border: none;
|
||||
border-radius: 6px;
|
||||
padding: 0 24px;
|
||||
height: 36px;
|
||||
font-size: 14px;
|
||||
line-height: 36px;
|
||||
cursor: pointer;
|
||||
}
|
||||
|
||||
.btn-default {
|
||||
background: #fff;
|
||||
color: #333;
|
||||
border: 1px solid #d9d9d9;
|
||||
border-radius: 6px;
|
||||
padding: 0 24px;
|
||||
height: 36px;
|
||||
font-size: 14px;
|
||||
line-height: 36px;
|
||||
cursor: pointer;
|
||||
}
|
||||
|
||||
.btn-sm {
|
||||
height: 32px;
|
||||
padding: 0 16px;
|
||||
font-size: 14px;
|
||||
line-height: 32px;
|
||||
}
|
||||
</style>
|
||||
@@ -1,288 +0,0 @@
|
||||
<template>
|
||||
<AdminLayout currentPage="/pages/subscription/index">
|
||||
<view class="toolbar">
|
||||
<view class="header-actions">
|
||||
<button class="btn-primary btn-sm" @click="showCreate = true">创建订阅</button>
|
||||
<button class="btn-default btn-sm" @click="onExport">导出报表</button>
|
||||
</view>
|
||||
</view>
|
||||
|
||||
<view class="page-card">
|
||||
<view class="stats-row">
|
||||
<view class="stat-item">
|
||||
<text class="stat-value">{{ stats.monthly_count ?? 0 }}</text>
|
||||
<text class="stat-label">月卡会员</text>
|
||||
</view>
|
||||
<view class="stat-item">
|
||||
<text class="stat-value">{{ stats.yearly_count ?? 0 }}</text>
|
||||
<text class="stat-label">年卡会员</text>
|
||||
</view>
|
||||
<view class="stat-item">
|
||||
<text class="stat-value">{{ stats.trial_count ?? 0 }}</text>
|
||||
<text class="stat-label">试用中</text>
|
||||
</view>
|
||||
<view class="stat-item">
|
||||
<text class="stat-value">¥{{ stats.monthly_revenue ?? 0 }}</text>
|
||||
<text class="stat-label">本月收入</text>
|
||||
</view>
|
||||
</view>
|
||||
</view>
|
||||
|
||||
<DataTable
|
||||
:columns="columns"
|
||||
:records="records"
|
||||
:page="page"
|
||||
:totalPages="totalPages"
|
||||
@page="onPage"
|
||||
>
|
||||
<template #toolbar>
|
||||
<view class="tabs">
|
||||
<view class="tab" :class="{ active: activeTab === 'all' }" @click="onTabFilter('all')">全部订阅</view>
|
||||
<view class="tab" :class="{ active: activeTab === 'monthly' }" @click="onTabFilter('monthly')">月卡</view>
|
||||
<view class="tab" :class="{ active: activeTab === 'yearly' }" @click="onTabFilter('yearly')">年卡</view>
|
||||
<view class="tab" :class="{ active: activeTab === 'trial' }" @click="onTabFilter('trial')">试用中</view>
|
||||
<view class="tab" :class="{ active: activeTab === 'expired' }" @click="onTabFilter('expired')">已过期</view>
|
||||
</view>
|
||||
</template>
|
||||
|
||||
<template #rows>
|
||||
<view class="t-row" v-for="item in records" :key="item.id">
|
||||
<text class="t-td flex2">{{ item.nickname || item.user_id }}</text>
|
||||
<text class="t-td flex1">{{ planText(item.plan) }}</text>
|
||||
<text class="t-td flex1">¥{{ item.amount || '-' }}</text>
|
||||
<text class="t-td flex2">{{ formatDate(item.start_time) }}</text>
|
||||
<text class="t-td flex2">{{ formatDate(item.expire_time) }}</text>
|
||||
<text class="t-td flex1">
|
||||
<text :class="statusBadge(item.status)">{{ statusText(item.status) }}</text>
|
||||
</text>
|
||||
<text class="t-td flex1">
|
||||
<text class="action-link" @click="onDetail(item)">详情</text>
|
||||
<text class="action-link" v-if="item.status === 1" @click="onExtend(item)">延期</text>
|
||||
<text class="action-link" v-if="item.status === 1" @click="onCancel(item)">取消</text>
|
||||
<text class="action-link" v-if="item.status === 2 || item.status === 3" @click="onRenew(item)">续费</text>
|
||||
</text>
|
||||
</view>
|
||||
</template>
|
||||
</DataTable>
|
||||
|
||||
<ConfirmModal
|
||||
:visible="showCreate"
|
||||
title="创建订阅"
|
||||
confirmText="创建"
|
||||
:loading="creating"
|
||||
@close="showCreate = false"
|
||||
@confirm="onCreate"
|
||||
>
|
||||
<view class="form-group">
|
||||
<text class="form-label">用户ID</text>
|
||||
<input class="form-input" v-model="createForm.user_id" placeholder="输入用户ID" />
|
||||
</view>
|
||||
<view class="form-group">
|
||||
<text class="form-label">方案</text>
|
||||
<input class="form-input" v-model="createForm.plan" placeholder="monthly/quarterly/yearly" />
|
||||
</view>
|
||||
<view class="form-group">
|
||||
<text class="form-label">天数</text>
|
||||
<input class="form-input" v-model="createForm.days" placeholder="订阅天数" type="number" />
|
||||
</view>
|
||||
</ConfirmModal>
|
||||
</AdminLayout>
|
||||
</template>
|
||||
|
||||
<script>
|
||||
import { get, post } from '../../utils/request'
|
||||
import { exportCSV } from '../../utils/export'
|
||||
import { formatDateShort } from '../../utils/format'
|
||||
import { listMixin } from '../../utils/useList'
|
||||
import AdminLayout from '../../components/AdminLayout.vue'
|
||||
import DataTable from '../../components/DataTable.vue'
|
||||
import ConfirmModal from '../../components/ConfirmModal.vue'
|
||||
|
||||
var PLAN_MAP = { monthly: '月卡', quarterly: '季卡', yearly: '年卡', trial: '试用' }
|
||||
var STATUS_TEXT = { 1: '生效中', 2: '已过期', 3: '已取消' }
|
||||
var STATUS_BADGE = { 1: 'badge badge-success', 2: 'badge badge-error', 3: 'badge badge-default' }
|
||||
|
||||
export default {
|
||||
components: { AdminLayout, DataTable, ConfirmModal },
|
||||
mixins: [listMixin('/api/v1/admin/subscriptions')],
|
||||
data() {
|
||||
return {
|
||||
stats: {},
|
||||
activeTab: 'all',
|
||||
columns: [
|
||||
{ key: 'user', label: '用户', flex: 2 },
|
||||
{ key: 'plan', label: '订阅类型', flex: 1 },
|
||||
{ key: 'amount', label: '订单金额', flex: 1 },
|
||||
{ key: 'start', label: '开始日期', flex: 2 },
|
||||
{ key: 'expire', label: '到期日期', flex: 2 },
|
||||
{ key: 'status', label: '状态', flex: 1 },
|
||||
{ key: 'actions', label: '操作', flex: 1 }
|
||||
],
|
||||
showCreate: false,
|
||||
creating: false,
|
||||
createForm: { user_id: '', plan: 'monthly', days: 30 }
|
||||
}
|
||||
},
|
||||
onShow() {
|
||||
this.reload()
|
||||
},
|
||||
methods: {
|
||||
reload() {
|
||||
this.loadList({ tab: this.activeTab })
|
||||
},
|
||||
onListLoaded(data) {
|
||||
if (data.stats) this.stats = data.stats
|
||||
},
|
||||
onTabFilter(tab) {
|
||||
this.activeTab = tab
|
||||
this.page = 1
|
||||
this.reload()
|
||||
},
|
||||
async onCreate() {
|
||||
this.creating = true
|
||||
try {
|
||||
await post('/api/v1/admin/subscriptions', {
|
||||
user_id: String(this.createForm.user_id).trim(),
|
||||
plan: this.createForm.plan,
|
||||
days: parseInt(this.createForm.days)
|
||||
})
|
||||
this.showCreate = false
|
||||
this.reload()
|
||||
uni.showToast({ title: '创建成功', icon: 'success' })
|
||||
} catch (e) {
|
||||
uni.showToast({ title: '创建失败', icon: 'none' })
|
||||
} finally {
|
||||
this.creating = false
|
||||
}
|
||||
},
|
||||
onDetail(item) {
|
||||
if (item.user_id) {
|
||||
uni.navigateTo({ url: '/pages/user-detail/index?user_id=' + item.user_id })
|
||||
}
|
||||
},
|
||||
onExtend(item) {
|
||||
var self = this
|
||||
uni.showModal({
|
||||
title: '延期订阅',
|
||||
content: '为用户 #' + item.user_id + ' 延期 30 天?',
|
||||
success: async function (res) {
|
||||
if (res.confirm) {
|
||||
try {
|
||||
await post('/api/v1/admin/subscriptions', {
|
||||
user_id: String(item.user_id),
|
||||
plan: item.plan || 'monthly',
|
||||
days: 30
|
||||
})
|
||||
uni.showToast({ title: '延期成功', icon: 'success' })
|
||||
self.reload()
|
||||
} catch (e) {
|
||||
uni.showToast({ title: '操作失败', icon: 'none' })
|
||||
}
|
||||
}
|
||||
}
|
||||
})
|
||||
},
|
||||
onCancel(item) {
|
||||
var self = this
|
||||
uni.showModal({
|
||||
title: '取消订阅',
|
||||
content: '确定要取消用户 #' + item.user_id + ' 的订阅吗?',
|
||||
success: async function (res) {
|
||||
if (res.confirm) {
|
||||
try {
|
||||
await post('/api/v1/admin/subscriptions/cancel', {
|
||||
subscription_id: item.subscription_id
|
||||
})
|
||||
uni.showToast({ title: '已取消', icon: 'success' })
|
||||
self.reload()
|
||||
} catch (e) {
|
||||
uni.showToast({ title: '操作失败', icon: 'none' })
|
||||
}
|
||||
}
|
||||
}
|
||||
})
|
||||
},
|
||||
onRenew(item) {
|
||||
this.createForm.user_id = String(item.user_id || '')
|
||||
this.createForm.plan = item.plan || 'monthly'
|
||||
this.createForm.days = 30
|
||||
this.showCreate = true
|
||||
},
|
||||
planText(plan) { return PLAN_MAP[plan] || plan || '-' },
|
||||
statusText(status) { return STATUS_TEXT[status] || '-' },
|
||||
statusBadge(status) { return STATUS_BADGE[status] || 'badge badge-default' },
|
||||
formatDate: formatDateShort,
|
||||
async onExport() {
|
||||
try {
|
||||
var data = await get('/api/v1/admin/subscriptions', { page: 1, page_size: 9999, tab: this.activeTab })
|
||||
var records = data.records || []
|
||||
exportCSV('subscriptions_' + new Date().toISOString().slice(0, 10) + '.csv',
|
||||
['用户', '订阅类型', '订单金额', '开始日期', '到期日期', '状态'],
|
||||
records.map(function (r) {
|
||||
return [r.nickname || r.user_id || '', PLAN_MAP[r.plan] || r.plan || '', r.amount || '', r.start_time ? String(r.start_time).slice(0, 10) : '', r.expire_time ? String(r.expire_time).slice(0, 10) : '', STATUS_TEXT[r.status] || '']
|
||||
})
|
||||
)
|
||||
uni.showToast({ title: '导出成功', icon: 'success' })
|
||||
} catch (e) {
|
||||
uni.showToast({ title: '导出失败', icon: 'none' })
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
</script>
|
||||
|
||||
<style scoped>
|
||||
@import '../../styles/common.css';
|
||||
|
||||
.stats-row {
|
||||
display: flex;
|
||||
gap: 16px;
|
||||
}
|
||||
|
||||
.stat-item {
|
||||
flex: 1;
|
||||
background: #fff;
|
||||
border-radius: 8px;
|
||||
padding: 16px;
|
||||
text-align: center;
|
||||
border: 1px solid #f0f0f0;
|
||||
}
|
||||
|
||||
.stat-value {
|
||||
display: block;
|
||||
font-size: 22px;
|
||||
font-weight: 700;
|
||||
color: #333;
|
||||
margin-bottom: 4px;
|
||||
}
|
||||
|
||||
.stat-label {
|
||||
font-size: 13px;
|
||||
color: #999;
|
||||
}
|
||||
|
||||
.tabs {
|
||||
display: flex;
|
||||
gap: 0;
|
||||
margin-bottom: 16px;
|
||||
border-bottom: 1px solid #f0f0f0;
|
||||
}
|
||||
|
||||
.tab {
|
||||
padding: 10px 20px;
|
||||
font-size: 14px;
|
||||
color: #666;
|
||||
cursor: pointer;
|
||||
border-bottom: 2px solid transparent;
|
||||
}
|
||||
|
||||
.tab.active {
|
||||
color: #E6508C;
|
||||
border-bottom-color: #E6508C;
|
||||
font-weight: 500;
|
||||
}
|
||||
|
||||
.action-link {
|
||||
margin-right: 8px;
|
||||
}
|
||||
</style>
|
||||
@@ -1,264 +0,0 @@
|
||||
<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 ? Math.round(user.total_duration / 3600000) + 'h' : '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.start_time) }}</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) {
|
||||
return status === 1 ? '已订阅' : '未订阅'
|
||||
},
|
||||
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>
|
||||
@@ -1,162 +0,0 @@
|
||||
<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) {
|
||||
return status === 1 ? '已订阅' : '未订阅'
|
||||
},
|
||||
subBadge(status) {
|
||||
return status === 1 ? 'badge badge-success' : '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.user_id, r.nickname || '', r.phone || '', r.device_count || 0, r.treatment_count || 0, r.subscription_status === 1 ? '已订阅' : '未订阅', 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>
|
||||
在新工单中引用
屏蔽一个用户