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
这个提交包含在:
Guoguo
2026-04-29 06:24:46 -07:00
父节点 52fb7799a3
当前提交 453f3854bd
修改 13 个文件,包含 225 行新增166 行删除
+346
查看文件
@@ -0,0 +1,346 @@
<template>
<view>
<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="$emit('navigate', 'record')">查看全部 </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="$emit('navigate', 'device')">
<view class="quick-icon" style="background:#E6508C">📱</view>
<text class="quick-label">设备管理</text>
<text class="quick-arrow"></text>
</view>
<view class="quick-item" @click="$emit('navigate', 'user')">
<view class="quick-icon" style="background:#E6508C">👥</view>
<text class="quick-label">用户管理</text>
<text class="quick-arrow"></text>
</view>
<view class="quick-item" @click="$emit('navigate', 'subscription')">
<view class="quick-icon" style="background:#E6508C">💳</view>
<text class="quick-label">订阅管理</text>
<text class="quick-arrow"></text>
</view>
<view class="quick-item" @click="$emit('navigate', 'record')">
<view class="quick-icon" style="background:#E6508C">📋</view>
<text class="quick-label">数据报表</text>
<text class="quick-arrow"></text>
</view>
</view>
</view>
</view>
</view>
</template>
<script>
import { get } from '../utils/request'
import { formatDate as formatDateUtil } from '../utils/format'
export default {
data() {
return {
stats: {},
subStats: {},
recentTreatments: []
}
},
mounted() {
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
}
}
</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>
@@ -0,0 +1,272 @@
<template>
<view>
<view class="back-link" @click="$emit('navigate', 'device')"> 返回设备列表</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>
</view>
</template>
<script>
import { get, post } from '../utils/request'
import { formatDate as formatDateUtil } from '../utils/format'
export default {
props: {
device_id: { type: String, default: '' }
},
data() {
return {
device: null,
deviceId: '',
statusMap: { 1: '未激活', 2: '在线', 3: '离线', 4: '故障' }
}
},
watch: {
device_id: {
immediate: true,
handler(val) {
if (val) {
this.deviceId = val
this.loadDevice()
}
}
}
},
methods: {
async loadDevice() {
try {
this.device = await get('/api/v1/admin/devices/' + this.deviceId)
} catch (e) {
uni.showToast({ title: '加载失败', icon: 'none' })
}
},
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() {
this.$emit('navigate', 'log', { 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>
+196
查看文件
@@ -0,0 +1,196 @@
<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>
<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="输入设备编号,每行一个&#10;例如:&#10;HOX001&#10;HOX002&#10;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>
+199
查看文件
@@ -0,0 +1,199 @@
<template>
<view>
<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>
</view>
</template>
<script>
import { get } from '../utils/request'
import { exportCSV } from '../utils/export'
import { formatDate as formatDateUtil } from '../utils/format'
export default {
name: 'LogView',
props: {
device_id: { type: String, default: '' }
},
data() {
return {
logs: [],
total: 0,
page: 1,
pageSize: 20,
filters: { type: '', device_id: '' }
}
},
computed: {
totalPages() {
return Math.ceil(this.total / this.pageSize) || 1
}
},
watch: {
device_id: {
immediate: true,
handler(val) {
this.filters.device_id = val || ''
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>
+195
查看文件
@@ -0,0 +1,195 @@
<template>
<view>
<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>
</view>
</template>
<script>
import { get } from '../utils/request'
import { exportCSV } from '../utils/export'
import { formatDate as formatDateUtil } from '../utils/format'
var REGION_MAP = {
1: '左脸', 2: '右脸', 4: '额头', 8: '下巴', 16: '鼻', 32: '左眼', 64: '右眼'
}
export default {
name: 'RecordView',
props: {
user_id: { type: String, default: '' }
},
data() {
return {
records: [],
total: 0,
page: 1,
pageSize: 20,
keyword: '',
dateFrom: '',
dateTo: ''
}
},
computed: {
totalPages() {
return Math.ceil(this.total / this.pageSize) || 1
}
},
watch: {
user_id: {
immediate: true,
handler(val) {
if (val) {
this.keyword = val
}
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 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>
+393
查看文件
@@ -0,0 +1,393 @@
<template>
<view>
<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>
</view>
</template>
<script>
import { get, post } from '../utils/request'
export default {
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: '' }
}
},
mounted() {
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>
@@ -0,0 +1,288 @@
<template>
<view>
<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>
</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 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 {
name: 'SubscriptionView',
components: { 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 }
}
},
mounted() {
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) {
this.$emit('navigate', 'user-detail', { user_id: String(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>
+268
查看文件
@@ -0,0 +1,268 @@
<template>
<view>
<view class="back-link" @click="$emit('navigate', 'user')"> 返回用户列表</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>
</view>
</template>
<script>
import { get } from '../utils/request'
import { formatDateShort } from '../utils/format'
export default {
props: {
user_id: { type: String, default: '' }
},
data() {
return {
user: null,
userId: ''
}
},
watch: {
user_id: {
immediate: true,
handler(val) {
if (val) {
this.userId = val
this.loadUser()
}
}
}
},
methods: {
async loadUser() {
try {
this.user = await get('/api/v1/admin/users/' + this.userId)
} catch (e) {
uni.showToast({ title: '加载失败', icon: 'none' })
}
},
goRecords() {
this.$emit('navigate', 'record', { 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>
+161
查看文件
@@ -0,0 +1,161 @@
<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-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>
</view>
</template>
<script>
import { get } from '../utils/request'
import { exportCSV } from '../utils/export'
import { formatDateShort } from '../utils/format'
export default {
name: 'UserListView',
data() {
return {
users: [],
total: 0,
page: 1,
pageSize: 20,
keyword: ''
}
},
computed: {
totalPages() {
return Math.ceil(this.total / this.pageSize) || 1
}
},
mounted() {
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) {
this.$emit('navigate', 'user-detail', { user_id: userId })
},
onViewRecords(userId) {
this.$emit('navigate', 'record', { 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>