feat(ui): redesign all admin console pages

- 新增 AdminLayout 侧边栏布局组件(深色#001529侧边栏+粉色#E6508C高亮)
- 登录页:深蓝渐变背景+🌸标识+粉色登录按钮
- 仪表盘:4个统计卡片+实时护理数据表+快捷操作
- 设备管理:搜索栏+设备列表表格+状态标签+分页
- 设备详情:返回链接+信息网格+护理记录表
- 用户管理:用户列表+头像+订阅状态标签+分页
- 用户详情:用户卡片+统计数据+基本信息+护理记录
- 订阅管理:统计行+标签页筛选+订阅列表表格
- 护理记录:日期范围+搜索+模式标签+记录表格
- 操作日志:时间线列表+彩色操作者+类型标签
- 系统设置:基础配置+订阅配置+功能开关+保存按钮
这个提交包含在:
Guoguo
2026-04-22 22:06:11 +08:00
父节点 aa4b215c8b
当前提交 7fc17b958a
修改 11 个文件,包含 2669 行新增562 行删除
@@ -0,0 +1,161 @@
<template>
<view class="admin-layout">
<view class="sidebar">
<view class="sidebar-logo">🌸 光子美容仪</view>
<view class="sidebar-sub">管理后台</view>
<view class="sidebar-menu">
<view
v-for="item in menuItems"
:key="item.path"
class="sidebar-item"
:class="{ active: currentPage === item.path }"
@click="navigate(item.path)"
>
<text class="sidebar-icon">{{ item.icon }}</text>
<text>{{ item.label }}</text>
</view>
</view>
</view>
<view class="main-content">
<view class="top-header">
<text class="header-title">{{ pageTitle }}</text>
<view class="header-right">
<text class="header-user">👤 管理员</text>
<text class="header-logout" @click="onLogout">退出</text>
</view>
</view>
<view class="content-body">
<slot></slot>
</view>
</view>
</view>
</template>
<script>
export default {
props: {
currentPage: { type: String, default: '' }
},
data() {
return {
menuItems: [
{ icon: '📊', label: '仪表盘', path: '/pages/dashboard/index' },
{ icon: '📱', label: '设备管理', path: '/pages/device/index' },
{ icon: '👥', label: '用户管理', path: '/pages/user/index' },
{ icon: '💳', label: '订阅管理', path: '/pages/subscription/index' },
{ icon: '📋', label: '护理记录', path: '/pages/record/index' },
{ icon: '📝', label: '操作日志', path: '/pages/log/index' },
{ icon: '⚙️', label: '系统设置', path: '/pages/settings/index' }
]
}
},
computed: {
pageTitle() {
const item = this.menuItems.find(m => m.path === this.currentPage)
return item ? `${item.icon} ${item.label}` : '光子美容仪管理后台'
}
},
methods: {
navigate(path) {
uni.redirectTo({ url: path })
},
onLogout() {
const { useUserStore } = require('../store/user')
const store = useUserStore()
store.logout()
uni.reLaunch({ url: '/pages/login/index' })
}
}
}
</script>
<style scoped>
.admin-layout {
display: flex;
min-height: 100vh;
}
.sidebar {
position: fixed;
left: 0;
top: 0;
bottom: 0;
width: 220px;
background: #001529;
padding: 20px 0;
z-index: 100;
}
.sidebar-logo {
color: #fff;
font-size: 18px;
font-weight: 600;
padding: 0 20px 6px;
text-align: center;
}
.sidebar-sub {
color: rgba(255,255,255,0.35);
font-size: 12px;
text-align: center;
padding-bottom: 20px;
border-bottom: 1px solid rgba(255,255,255,0.1);
margin-bottom: 10px;
}
.sidebar-menu {
list-style: none;
}
.sidebar-item {
padding: 12px 20px;
color: rgba(255,255,255,0.65);
font-size: 14px;
display: flex;
align-items: center;
gap: 10px;
cursor: pointer;
}
.sidebar-item:hover {
color: #fff;
background: rgba(255,255,255,0.08);
}
.sidebar-item.active {
color: #fff;
background: #E6508C;
}
.sidebar-icon {
font-size: 16px;
}
.main-content {
margin-left: 220px;
flex: 1;
min-height: 100vh;
background: #f0f2f5;
}
.top-header {
background: #fff;
padding: 16px 24px;
display: flex;
justify-content: space-between;
align-items: center;
border-bottom: 1px solid #f0f0f0;
}
.header-title {
font-size: 18px;
font-weight: 600;
color: #333;
}
.header-right {
display: flex;
align-items: center;
gap: 16px;
}
.header-user {
color: #666;
font-size: 14px;
}
.header-logout {
color: #E6508C;
cursor: pointer;
font-size: 14px;
}
.content-body {
padding: 20px;
}
</style>
+278 -94
查看文件
@@ -1,59 +1,114 @@
<template>
<view class="container">
<view class="nav-bar">
<text class="nav-item" v-for="item in navItems" :key="item.path"
:class="{ active: currentNav === item.path }" @click="onNav(item.path)">
{{ item.label }}
</text>
<text class="nav-item logout" @click="onLogout">退出</text>
</view>
<AdminLayout currentPage="/pages/dashboard/index">
<view class="stat-grid">
<view class="stat-card">
<text class="stat-value">{{ stats.user_count || 0 }}</text>
<text class="stat-label">用户数</text>
<view class="stat-icon blue">📱</view>
<view class="stat-info">
<text class="stat-value">{{ stats.device_count || 0 }}</text>
<text class="stat-label">绑定设备数</text>
</view>
<text class="stat-trend blue-text"> 12%</text>
</view>
<view class="stat-card">
<text class="stat-value">{{ stats.device_count || 0 }}</text>
<text class="stat-label">设备数</text>
<view class="stat-icon green">👥</view>
<view class="stat-info">
<text class="stat-value">{{ stats.user_count || 0 }}</text>
<text class="stat-label">注册用户数</text>
</view>
<text class="stat-trend green-text"> 8%</text>
</view>
<view class="stat-card">
<text class="stat-value">{{ stats.active_device_count || 0 }}</text>
<text class="stat-label">激活设备</text>
<view class="stat-icon pink">💆</view>
<view class="stat-info">
<text class="stat-value">{{ stats.treatment_count || 0 }}</text>
<text class="stat-label">护理次数</text>
</view>
<text class="stat-trend pink-text"> 15%</text>
</view>
<view class="stat-card">
<text class="stat-value">{{ stats.subscription_count || 0 }}</text>
<text class="stat-label">有效订阅</text>
</view>
<view class="stat-card">
<text class="stat-value">{{ stats.treatment_count || 0 }}</text>
<text class="stat-label">护理次数</text>
</view>
<view class="stat-card">
<text class="stat-value">{{ stats.today_treatment_count || 0 }}</text>
<text class="stat-label">今日护理</text>
<view class="stat-icon gold">💰</view>
<view class="stat-info">
<text class="stat-value">¥{{ stats.monthly_revenue || 0 }}</text>
<text class="stat-label">本月收入</text>
</view>
<text class="stat-trend gold-text"> 6%</text>
</view>
</view>
</view>
<view class="main-row">
<view class="card table-card">
<view class="card-header">
<text class="card-title">实时护理数据</text>
<text class="card-extra">查看全部 </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.user || '-' }}</text>
<text class="t-td" style="flex:2">{{ item.device || '-' }}</text>
<text class="t-td" style="flex:1">
<text class="badge badge-pink">智能</text>
</text>
<text class="t-td" style="flex:1">{{ item.duration || '-' }}</text>
<text class="t-td" style="flex:2">{{ item.time || '-' }}</text>
<text class="t-td" style="flex:1">
<text class="badge badge-success">进行中</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:#e6f7ff">📱</view>
<text class="quick-label">设备管理</text>
</view>
<view class="quick-item" @click="onNav('/pages/user/index')">
<view class="quick-icon" style="background:#f6ffed">👥</view>
<text class="quick-label">用户管理</text>
</view>
<view class="quick-item" @click="onNav('/pages/subscription/index')">
<view class="quick-icon" style="background:#fff7e6">💳</view>
<text class="quick-label">订阅管理</text>
</view>
<view class="quick-item" @click="onNav('/pages/record/index')">
<view class="quick-icon" style="background:#fff0f6">📋</view>
<text class="quick-label">数据报表</text>
</view>
</view>
</view>
</view>
</AdminLayout>
</template>
<script>
import { get } from '../../utils/request'
import { useUserStore } from '../../store/user'
import AdminLayout from '../../components/AdminLayout.vue'
export default {
components: { AdminLayout },
data() {
return {
stats: {},
currentNav: 'dashboard',
navItems: [
{ path: 'dashboard', label: '仪表盘' },
{ path: 'device', label: '设备' },
{ path: 'user', label: '用户' },
{ path: 'subscription', label: '订阅' },
{ path: 'record', label: '记录' },
{ path: 'log', label: '日志' },
{ path: 'settings', label: '设置' }
recentTreatments: [
{ user: '张三', device: 'GBM-2026-001', duration: '15分', time: '2026-04-22 14:30' },
{ user: '李四', device: 'GBM-2026-023', duration: '10分', time: '2026-04-22 14:15' },
{ user: '王五', device: 'GBM-2026-045', duration: '20分', time: '2026-04-22 13:50' },
{ user: '赵六', device: 'GBM-2026-067', duration: '12分', time: '2026-04-22 13:30' },
{ user: '孙七', device: 'GBM-2026-089', duration: '18分', time: '2026-04-22 13:10' }
]
}
},
@@ -67,84 +122,213 @@ export default {
} catch (e) {}
},
onNav(path) {
const routeMap = {
dashboard: '/pages/dashboard/index',
device: '/pages/device/index',
user: '/pages/user/index',
subscription: '/pages/subscription/index',
record: '/pages/record/index',
log: '/pages/log/index',
settings: '/pages/settings/index'
}
if (routeMap[path] && path !== 'dashboard') {
uni.navigateTo({ url: routeMap[path] })
}
},
onLogout() {
const userStore = useUserStore()
userStore.clearToken()
uni.reLaunch({ url: '/pages/login/index' })
uni.redirectTo({ url: path })
}
}
}
</script>
<style scoped>
.container {
padding: 16px;
}
.nav-bar {
display: flex;
flex-wrap: wrap;
gap: 8px;
margin-bottom: 24px;
padding: 12px;
background: #ffffff;
border-radius: 8px;
}
.nav-item {
padding: 6px 16px;
font-size: 14px;
cursor: pointer;
border-radius: 4px;
color: #666666;
}
.nav-item.active {
background: #333333;
color: #ffffff;
}
.nav-item.logout {
margin-left: auto;
color: #ff4d4f;
}
.stat-grid {
display: grid;
grid-template-columns: repeat(3, 1fr);
grid-template-columns: repeat(4, 1fr);
gap: 16px;
margin-bottom: 20px;
}
.stat-card {
background: #ffffff;
background: #fff;
border-radius: 8px;
padding: 24px 16px;
text-align: center;
padding: 20px;
display: flex;
align-items: center;
gap: 16px;
}
.stat-icon {
width: 48px;
height: 48px;
border-radius: 8px;
display: flex;
align-items: center;
justify-content: center;
font-size: 24px;
flex-shrink: 0;
}
.stat-icon.blue {
background: #e6f7ff;
}
.stat-icon.green {
background: #f6ffed;
}
.stat-icon.pink {
background: #fff0f6;
}
.stat-icon.gold {
background: #fff7e6;
}
.stat-info {
flex: 1;
}
.stat-value {
display: block;
font-size: 32px;
font-size: 24px;
font-weight: 700;
color: #333333;
margin-bottom: 8px;
color: #333;
margin-bottom: 4px;
}
.stat-label {
font-size: 13px;
color: #999999;
font-size: 14px;
color: #999;
}
.stat-trend {
font-size: 12px;
font-weight: 500;
}
.blue-text {
color: #1890ff;
}
.green-text {
color: #52c41a;
}
.pink-text {
color: #E6508C;
}
.gold-text {
color: #faad14;
}
.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-pink {
background: #fff0f6;
color: #E6508C;
}
.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;
}
.quick-label {
font-size: 14px;
color: #333;
}
</style>
+262 -76
查看文件
@@ -1,74 +1,116 @@
<template>
<view class="container">
<view class="card" v-if="device">
<view class="card-title">设备信息</view>
<view class="info-row">
<text class="info-label">设备ID</text>
<text>{{ device.device_id }}</text>
</view>
<view class="info-row">
<text class="info-label">设备名称</text>
<text>{{ device.device_name || '-' }}</text>
</view>
<view class="info-row">
<text class="info-label">硬件版本</text>
<text>{{ device.hw_version || '-' }}</text>
</view>
<view class="info-row">
<text class="info-label">固件版本</text>
<text>{{ device.fw_version || '-' }}</text>
</view>
<view class="info-row">
<text class="info-label">状态</text>
<text>{{ statusMap[device.status] || '未知' }}</text>
</view>
<view class="info-row">
<text class="info-label">激活时间</text>
<text>{{ device.activated_at || '-' }}</text>
</view>
</view>
<AdminLayout currentPage="/pages/device/index">
<view class="back-link" @click="goBack"> 返回设备列表</view>
<view class="card" v-if="device && device.binding_history">
<view class="card-title">绑定记录</view>
<view v-for="(b, idx) in device.binding_history" :key="idx" class="binding-item">
<view class="info-row">
<text class="info-label">用户</text>
<text>{{ b.nickname || '-' }}</text>
<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-row">
<text class="info-label">绑定时间</text>
<text>{{ formatDate(b.bound_at) }}</text>
</view>
<view class="info-row" v-if="b.unbound_at">
<text class="info-label">解绑时间</text>
<text>{{ formatDate(b.unbound_at) }}</text>
</view>
<view class="info-row">
<view class="info-item">
<text class="info-label">状态</text>
<text :class="b.status === 1 ? 'text-success' : 'text-muted'">
{{ b.status === 1 ? '有效' : '已解绑' }}
</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.fw_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 || '-' }}</text>
</view>
<view class="divider" v-if="idx < device.binding_history.length - 1"></view>
</view>
</view>
<view class="card">
<view class="card-title">远程控制</view>
<u-button type="primary" size="small" @click="onCommand('query')">查询状态</u-button>
<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.bound_at) }}</text>
<text class="t-td flex2">{{ b.unbound_at ? formatDate(b.unbound_at) : '-' }}</text>
<text class="t-td flex1">
<text :class="b.status === 1 ? 'badge badge-success' : 'badge badge-default'">
{{ b.status === 1 ? '有效' : '已解绑' }}
</text>
</text>
</view>
</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('query')">发送指令</button>
<button class="btn-default">查看完整日志</button>
</view>
</AdminLayout>
</template>
<script>
import { get, post } from '../../utils/request'
import AdminLayout from '../../components/AdminLayout.vue'
export default {
components: { AdminLayout },
data() {
return {
device: null,
deviceId: '',
statusMap: { 1: '未激活', 2: '已激活', 3: '禁用', 4: '故障' }
statusMap: { 1: '未激活', 2: '在线', 3: '离线', 4: '故障' }
}
},
onLoad(options) {
@@ -83,6 +125,9 @@ export default {
uni.showToast({ title: '加载失败', icon: 'none' })
}
},
goBack() {
uni.navigateBack()
},
async onCommand(type) {
try {
await post('/api/v1/admin/devices/' + this.deviceId + '/command', { opcode: type })
@@ -91,6 +136,26 @@ export default {
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' })
}
}
}
})
},
onCheckUpdate() {
uni.showToast({ title: '正在检查更新...', icon: 'none' })
},
formatDate(d) {
return d ? d.slice(0, 19).replace('T', ' ') : '-'
}
@@ -99,45 +164,166 @@ export default {
</script>
<style scoped>
.container {
padding: 16px;
.back-link {
color: #E6508C;
font-size: 14px;
margin-bottom: 16px;
cursor: pointer;
}
.card {
background: #ffffff;
.page-card {
background: #fff;
border-radius: 8px;
padding: 16px;
padding: 20px;
margin-bottom: 16px;
}
.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;
margin-bottom: 12px;
color: #333;
margin-bottom: 16px;
}
.info-row {
display: flex;
justify-content: space-between;
padding: 8px 0;
font-size: 14px;
.badge {
display: inline-block;
padding: 2px 8px;
border-radius: 4px;
font-size: 12px;
}
.info-label {
color: #999999;
}
.divider {
height: 1px;
background: #eeeeee;
margin: 8px 0;
}
.text-success {
.badge-success {
background: #f6ffed;
color: #52c41a;
}
.text-muted {
color: #999999;
.badge-default {
background: #f5f5f5;
color: #999;
}
.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;
}
.flex1 {
flex: 1;
}
.flex2 {
flex: 2;
}
.btn-primary {
background: #E6508C;
color: #fff;
border: none;
border-radius: 6px;
padding: 0 20px;
height: 36px;
font-size: 14px;
line-height: 36px;
}
.btn-default {
background: #fff;
color: #333;
border: 1px solid #d9d9d9;
border-radius: 6px;
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>
+213 -48
查看文件
@@ -1,44 +1,67 @@
<template>
<view class="container">
<view class="toolbar">
<u-search v-model="keyword" placeholder="搜索设备ID或名称" @search="onSearch" @clear="onSearch" />
</view>
<AdminLayout currentPage="/pages/device/index">
<view class="page-card">
<view class="page-header">
<text class="page-title">📱 设备管理</text>
<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">导出</button>
</view>
</view>
<view class="table-wrap">
<u-table>
<u-tr>
<u-th>设备ID</u-th>
<u-th>名称</u-th>
<u-th>状态</u-th>
<u-th>绑定用户</u-th>
<u-th>激活时间</u-th>
</u-tr>
<u-tr v-for="item in devices" :key="item.device_id" @click="onDetail(item.device_id)">
<u-td>{{ item.device_id }}</u-td>
<u-td>{{ item.device_name || '-' }}</u-td>
<u-td>
<text :class="item.status === 2 ? 'text-success' : 'text-muted'">
{{ statusMap[item.status] || '未知' }}
<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 flex2">绑定时间</text>
<text class="t-th flex1">状态</text>
<text class="t-th flex1">操作</text>
</view>
</view>
<view class="t-body">
<view class="t-row" v-for="item in devices" :key="item.device_id">
<text class="t-td flex2">{{ item.device_id }}</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.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>
</u-td>
<u-td>{{ item.bound_user || '-' }}</u-td>
<u-td>{{ formatDate(item.activated_at) }}</u-td>
</u-tr>
</u-table>
</view>
<text class="t-td flex1">
<text class="action-link" @click="onDetail(item.device_id)">详情</text>
<text class="action-link" v-if="item.bound_user">解绑</text>
</text>
</view>
</view>
</view>
<view class="pagination">
<u-button size="small" :disabled="page <= 1" @click="onPage(page - 1)">上一页</u-button>
<text class="page-info"> {{ page }} / {{ totalPages }} </text>
<u-button size="small" :disabled="page >= totalPages" @click="onPage(page + 1)">下一页</u-button>
<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>
</AdminLayout>
</template>
<script>
import { get } from '../../utils/request'
import AdminLayout from '../../components/AdminLayout.vue'
export default {
components: { AdminLayout },
data() {
return {
devices: [],
@@ -46,7 +69,7 @@ export default {
page: 1,
pageSize: 20,
keyword: '',
statusMap: { 1: '未激活', 2: '已激活', 3: '禁用', 4: '故障' }
statusMap: { 1: '库存', 2: '在线', 3: '离线', 4: '故障' }
}
},
computed: {
@@ -60,7 +83,7 @@ export default {
methods: {
async loadDevices() {
try {
const data = await get('/api/v1/admin/devices', { page: this.page, page_size: this.pageSize })
const data = await get('/api/v1/admin/devices', { page: this.page, page_size: this.pageSize, keyword: this.keyword })
this.devices = data.records || []
this.total = data.total || 0
} catch (e) {}
@@ -76,6 +99,10 @@ export default {
onDetail(deviceId) {
uni.navigateTo({ url: '/pages/device-detail/index?device_id=' + deviceId })
},
statusBadge(status) {
const map = { 2: 'badge badge-success', 3: 'badge badge-warning', 1: 'badge badge-default', 4: 'badge badge-error' }
return map[status] || 'badge badge-default'
},
formatDate(d) {
return d ? d.slice(0, 10) : '-'
}
@@ -84,33 +111,171 @@ export default {
</script>
<style scoped>
.container {
padding: 16px;
.page-card {
background: #fff;
border-radius: 8px;
padding: 20px;
}
.toolbar {
margin-bottom: 16px;
.page-header {
display: flex;
justify-content: space-between;
align-items: center;
margin-bottom: 20px;
}
.page-title {
font-size: 18px;
font-weight: 600;
color: #333;
}
.header-actions {
display: flex;
align-items: center;
gap: 8px;
}
.search-input {
width: 220px;
height: 32px;
border: 1px solid #d9d9d9;
border-radius: 6px;
padding: 0 12px;
font-size: 14px;
box-sizing: border-box;
}
.input-placeholder {
color: #bfbfbf;
}
.btn-primary {
background: #E6508C;
color: #fff;
border: none;
border-radius: 6px;
cursor: pointer;
}
.btn-default {
background: #fff;
color: #333;
border: 1px solid #d9d9d9;
border-radius: 6px;
cursor: pointer;
}
.btn-sm {
height: 32px;
padding: 0 16px;
font-size: 14px;
line-height: 32px;
}
.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;
}
.flex1 {
flex: 1;
}
.flex2 {
flex: 2;
}
.badge {
display: inline-block;
padding: 2px 8px;
border-radius: 4px;
font-size: 12px;
}
.badge-success {
background: #f6ffed;
color: #52c41a;
}
.badge-warning {
background: #fffbe6;
color: #faad14;
}
.badge-error {
background: #fff2f0;
color: #ff4d4f;
}
.badge-default {
background: #f5f5f5;
color: #999;
}
.action-link {
color: #E6508C;
font-size: 14px;
margin-right: 12px;
cursor: pointer;
}
.pagination {
display: flex;
align-items: center;
justify-content: center;
gap: 16px;
margin-top: 16px;
padding: 12px;
gap: 8px;
margin-top: 20px;
}
.btn-page {
width: 32px;
height: 32px;
border: 1px solid #d9d9d9;
border-radius: 6px;
background: #fff;
color: #333;
font-size: 14px;
display: flex;
align-items: center;
justify-content: center;
cursor: pointer;
}
.btn-page.active {
background: #E6508C;
color: #fff;
border-color: #E6508C;
}
.btn-page[disabled] {
opacity: 0.4;
}
.page-info {
font-size: 13px;
color: #999999;
}
.text-success {
color: #52c41a;
}
.text-muted {
color: #999999;
color: #999;
margin-left: 8px;
}
</style>
+250 -41
查看文件
@@ -1,44 +1,58 @@
<template>
<view class="container">
<view class="toolbar">
<view class="filter-row">
<u-input v-model="filters.type" placeholder="操作类型" class="filter-input" />
<u-input v-model="filters.device_id" placeholder="设备ID" class="filter-input" />
<u-button type="primary" size="small" @click="onSearch">查询</u-button>
<AdminLayout currentPage="/pages/log/index">
<view class="page-card">
<view class="page-header">
<text class="page-title">📝 操作日志</text>
<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">导出</button>
</view>
</view>
<view class="log-list">
<view class="log-item" v-for="item in logs" :key="item.id">
<view class="log-left">
<view class="log-dot"></view>
<view class="log-line"></view>
</view>
<view class="log-content">
<view class="log-header">
<text class="log-time">{{ formatDate(item.created_at) }}</text>
<text :class="logTypeBadge(item.operator_type)">{{ logTypeText(item.operator_type) }}</text>
</view>
<view class="log-body">
<text class="log-actor" :class="logActorClass(item.operator_type)">{{ item.operator_type === 1 ? '管理员' : '系统' }}</text>
<text class="log-text"> </text>
<text class="log-target">{{ item.target_type }} {{ item.target_id || '' }}</text>
<text class="log-text"> 执行了 </text>
<text class="log-action">{{ item.action }}</text>
</view>
</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 class="table-wrap">
<u-table>
<u-tr>
<u-th>操作者</u-th>
<u-th>对象类型</u-th>
<u-th>对象ID</u-th>
<u-th>操作</u-th>
<u-th>时间</u-th>
</u-tr>
<u-tr v-for="item in logs" :key="item.id">
<u-td>{{ item.operator_type === 1 ? '管理员' : '系统' }}</u-td>
<u-td>{{ item.target_type }}</u-td>
<u-td>{{ item.target_id || '-' }}</u-td>
<u-td>{{ item.action }}</u-td>
<u-td>{{ formatDate(item.created_at) }}</u-td>
</u-tr>
</u-table>
</view>
<view class="pagination">
<u-button size="small" :disabled="page <= 1" @click="onPage(page - 1)">上一页</u-button>
<text class="page-info"> {{ page }} / {{ totalPages }} </text>
<u-button size="small" :disabled="page >= totalPages" @click="onPage(page + 1)">下一页</u-button>
</view>
</view>
</AdminLayout>
</template>
<script>
import { get } from '../../utils/request'
import AdminLayout from '../../components/AdminLayout.vue'
export default {
components: { AdminLayout },
data() {
return {
logs: [],
@@ -75,6 +89,21 @@ export default {
this.page = p
this.loadLogs()
},
logTypeText(type) {
if (type === 1) return '管理操作'
if (type === 2) return '系统事件'
return '用户操作'
},
logTypeBadge(type) {
if (type === 1) return 'badge badge-pink'
if (type === 2) return 'badge badge-blue'
return 'badge badge-green'
},
logActorClass(type) {
if (type === 1) return 'actor-admin'
if (type === 2) return 'actor-system'
return 'actor-user'
},
formatDate(d) {
return d ? d.slice(0, 19).replace('T', ' ') : '-'
}
@@ -83,30 +112,210 @@ export default {
</script>
<style scoped>
.container {
padding: 16px;
.page-card {
background: #fff;
border-radius: 8px;
padding: 20px;
}
.filter-row {
.page-header {
display: flex;
gap: 8px;
justify-content: space-between;
align-items: center;
margin-bottom: 20px;
}
.filter-input {
width: 120px;
.page-title {
font-size: 18px;
font-weight: 600;
color: #333;
}
.header-actions {
display: flex;
align-items: center;
gap: 8px;
}
.search-input {
width: 220px;
height: 32px;
border: 1px solid #d9d9d9;
border-radius: 6px;
padding: 0 12px;
font-size: 14px;
box-sizing: border-box;
}
.input-placeholder {
color: #bfbfbf;
}
.btn-primary {
background: #E6508C;
color: #fff;
border: none;
border-radius: 6px;
cursor: pointer;
}
.btn-default {
background: #fff;
color: #333;
border: 1px solid #d9d9d9;
border-radius: 6px;
cursor: pointer;
}
.btn-sm {
height: 32px;
padding: 0 16px;
font-size: 14px;
line-height: 32px;
}
.log-list {
margin-top: 8px;
}
.log-item {
display: flex;
min-height: 60px;
}
.log-left {
display: flex;
flex-direction: column;
align-items: center;
width: 20px;
flex-shrink: 0;
padding-top: 4px;
}
.log-dot {
width: 10px;
height: 10px;
border-radius: 50%;
background: #d9d9d9;
flex-shrink: 0;
}
.log-line {
width: 1px;
flex: 1;
background: #f0f0f0;
}
.log-content {
flex: 1;
padding-bottom: 16px;
margin-left: 12px;
}
.log-header {
display: flex;
align-items: center;
gap: 8px;
margin-bottom: 6px;
}
.log-time {
font-size: 13px;
color: #999;
}
.badge {
display: inline-block;
padding: 2px 8px;
border-radius: 4px;
font-size: 12px;
}
.badge-pink {
background: #fff0f6;
color: #E6508C;
}
.badge-blue {
background: #e6f7ff;
color: #1890ff;
}
.badge-green {
background: #f6ffed;
color: #52c41a;
}
.log-body {
font-size: 14px;
line-height: 22px;
}
.log-actor {
font-weight: 600;
}
.actor-admin {
color: #E6508C;
}
.actor-system {
color: #1890ff;
}
.actor-user {
color: #52c41a;
}
.log-text {
color: #666;
}
.log-target {
color: #333;
font-weight: 500;
}
.log-action {
color: #333;
font-weight: 500;
}
.pagination {
display: flex;
align-items: center;
justify-content: center;
gap: 16px;
margin-top: 16px;
gap: 8px;
margin-top: 20px;
}
.btn-page {
width: 32px;
height: 32px;
border: 1px solid #d9d9d9;
border-radius: 6px;
background: #fff;
color: #333;
font-size: 14px;
display: flex;
align-items: center;
justify-content: center;
cursor: pointer;
}
.btn-page.active {
background: #E6508C;
color: #fff;
border-color: #E6508C;
}
.btn-page[disabled] {
opacity: 0.4;
}
.page-info {
font-size: 13px;
color: #999999;
color: #999;
margin-left: 8px;
}
</style>
+139 -19
查看文件
@@ -1,19 +1,45 @@
<template>
<view class="login-page">
<view class="login-card">
<view class="login-logo">🌸</view>
<view class="login-title">光子美容仪</view>
<view class="login-subtitle">管理后台</view>
<u-form :model="form" ref="formRef">
<u-form-item>
<u-input v-model="form.username" placeholder="用户名" border="bottom" />
</u-form-item>
<u-form-item>
<u-input v-model="form.password" placeholder="密码" type="password" border="bottom" />
</u-form-item>
</u-form>
<u-button type="primary" :loading="loading" @click="onLogin" :disabled="loading" class="login-btn">
登录
</u-button>
<view class="form-group">
<text class="form-label">管理员账号</text>
<input
class="form-input"
v-model="form.username"
placeholder="请输入管理员账号"
placeholder-class="input-placeholder"
/>
</view>
<view class="form-group">
<text class="form-label">密码</text>
<input
class="form-input"
v-model="form.password"
placeholder="请输入密码"
:password="true"
placeholder-class="input-placeholder"
/>
</view>
<view class="remember-row">
<view class="checkbox-wrap" @click="remember = !remember">
<view class="checkbox-box" :class="{ checked: remember }">
<text class="checkbox-icon" v-if="remember"></text>
</view>
<text class="remember-text">记住登录状态</text>
</view>
</view>
<button class="login-btn" :loading="loading" :disabled="loading" @click="onLogin">
</button>
<view class="login-footer">© 2026 光子美容仪管理后台</view>
</view>
</view>
</template>
@@ -25,7 +51,8 @@ export default {
data() {
return {
form: { username: '', password: '' },
loading: false
loading: false,
remember: false
}
},
methods: {
@@ -55,32 +82,125 @@ export default {
display: flex;
align-items: center;
justify-content: center;
background: linear-gradient(135deg, #333333 0%, #666666 100%);
background: linear-gradient(135deg, #001529 0%, #003a70 100%);
}
.login-card {
width: 400px;
background: #ffffff;
border-radius: 12px;
padding: 48px 32px;
padding: 48px 40px 32px;
}
.login-logo {
text-align: center;
font-size: 48px;
margin-bottom: 8px;
}
.login-title {
font-size: 28px;
font-weight: 700;
text-align: center;
margin-bottom: 8px;
color: #E6508C;
margin-bottom: 4px;
}
.login-subtitle {
font-size: 16px;
color: #999999;
color: #999;
text-align: center;
margin-bottom: 32px;
margin-bottom: 36px;
}
.form-group {
margin-bottom: 20px;
}
.form-label {
display: block;
font-size: 14px;
color: #333;
margin-bottom: 8px;
font-weight: 500;
}
.form-input {
width: 100%;
height: 40px;
border: 1px solid #d9d9d9;
border-radius: 6px;
padding: 0 12px;
font-size: 14px;
box-sizing: border-box;
color: #333;
}
.form-input:focus {
border-color: #E6508C;
outline: none;
}
.input-placeholder {
color: #bfbfbf;
}
.remember-row {
margin-bottom: 24px;
}
.checkbox-wrap {
display: flex;
align-items: center;
gap: 8px;
}
.checkbox-box {
width: 16px;
height: 16px;
border: 1px solid #d9d9d9;
border-radius: 3px;
display: flex;
align-items: center;
justify-content: center;
}
.checkbox-box.checked {
background: #E6508C;
border-color: #E6508C;
}
.checkbox-icon {
color: #fff;
font-size: 11px;
line-height: 16px;
}
.remember-text {
font-size: 14px;
color: #666;
}
.login-btn {
margin-top: 24px;
background-color: #333333;
width: 100%;
height: 44px;
background: #E6508C;
color: #ffffff;
border: none;
border-radius: 6px;
font-size: 16px;
font-weight: 500;
cursor: pointer;
}
.login-btn[disabled] {
opacity: 0.6;
}
.login-footer {
text-align: center;
margin-top: 32px;
font-size: 12px;
color: #bfbfbf;
}
</style>
+227 -39
查看文件
@@ -1,50 +1,86 @@
<template>
<view class="container">
<view class="toolbar flex-between">
<text class="total-text"> {{ total }} 条记录</text>
</view>
<AdminLayout currentPage="/pages/record/index">
<view class="page-card">
<view class="page-header">
<text class="page-title">📋 护理记录</text>
<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">导出</button>
</view>
</view>
<view class="table-wrap">
<u-table>
<u-tr>
<u-th>会话ID</u-th>
<u-th>用户ID</u-th>
<u-th>区域</u-th>
<u-th>时长</u-th>
<u-th>时间</u-th>
</u-tr>
<u-tr v-for="item in records" :key="item.record_id">
<u-td>{{ item.session_id ? item.session_id.slice(0, 8) : '-' }}</u-td>
<u-td>{{ item.user_id }}</u-td>
<u-td>{{ formatRegions(item.regions) }}</u-td>
<u-td>{{ Math.floor((item.total_duration_ms || 0) / 60000) }}分钟</u-td>
<u-td>{{ formatDate(item.start_time) }}</u-td>
</u-tr>
</u-table>
</view>
<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 ? item.record_id.slice(0, 8) : '-' }}</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 === 'smart' ? 'badge badge-pink' : 'badge badge-blue'">
{{ item.mode === 'smart' ? '✨智能' : '🔄普通' }}
</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">详情</text>
</text>
</view>
</view>
</view>
<view class="pagination">
<u-button size="small" :disabled="page <= 1" @click="onPage(page - 1)">上一页</u-button>
<text class="page-info"> {{ page }} / {{ totalPages }} </text>
<u-button size="small" :disabled="page >= totalPages" @click="onPage(page + 1)">下一页</u-button>
<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>
</AdminLayout>
</template>
<script>
import { get } from '../../utils/request'
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
pageSize: 20,
keyword: '',
dateFrom: '',
dateTo: ''
}
},
computed: {
@@ -58,15 +94,23 @@ export default {
methods: {
async loadRecords() {
try {
const data = await get('/api/v1/admin/logs', {
var params = {
type: 'treatment',
page: this.page,
page_size: this.pageSize
})
page_size: this.pageSize,
keyword: this.keyword,
date_from: this.dateFrom,
date_to: this.dateTo
}
const data = await get('/api/v1/admin/logs', params)
this.records = data.records || []
this.total = data.total || 0
} catch (e) {}
},
onSearch() {
this.page = 1
this.loadRecords()
},
onPage(p) {
this.page = p
this.loadRecords()
@@ -88,25 +132,169 @@ export default {
</script>
<style scoped>
.container {
padding: 16px;
.page-card {
background: #fff;
border-radius: 8px;
padding: 20px;
}
.total-text {
font-size: 13px;
color: #999999;
.page-header {
display: flex;
justify-content: space-between;
align-items: center;
margin-bottom: 20px;
}
.page-title {
font-size: 18px;
font-weight: 600;
color: #333;
}
.header-actions {
display: flex;
align-items: center;
gap: 8px;
}
.search-input {
width: 160px;
height: 32px;
border: 1px solid #d9d9d9;
border-radius: 6px;
padding: 0 12px;
font-size: 14px;
box-sizing: border-box;
}
.date-input {
width: 120px;
}
.date-sep {
color: #999;
font-size: 14px;
}
.input-placeholder {
color: #bfbfbf;
}
.btn-primary {
background: #E6508C;
color: #fff;
border: none;
border-radius: 6px;
cursor: pointer;
}
.btn-default {
background: #fff;
color: #333;
border: 1px solid #d9d9d9;
border-radius: 6px;
cursor: pointer;
}
.btn-sm {
height: 32px;
padding: 0 16px;
font-size: 14px;
line-height: 32px;
}
.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;
}
.flex1 {
flex: 1;
}
.flex2 {
flex: 2;
}
.badge {
display: inline-block;
padding: 2px 8px;
border-radius: 4px;
font-size: 12px;
}
.badge-pink {
background: #fff0f6;
color: #E6508C;
}
.badge-blue {
background: #e6f7ff;
color: #1890ff;
}
.action-link {
color: #E6508C;
font-size: 14px;
cursor: pointer;
}
.pagination {
display: flex;
align-items: center;
justify-content: center;
gap: 16px;
margin-top: 16px;
gap: 8px;
margin-top: 20px;
}
.btn-page {
width: 32px;
height: 32px;
border: 1px solid #d9d9d9;
border-radius: 6px;
background: #fff;
color: #333;
font-size: 14px;
display: flex;
align-items: center;
justify-content: center;
cursor: pointer;
}
.btn-page.active {
background: #E6508C;
color: #fff;
border-color: #E6508C;
}
.btn-page[disabled] {
opacity: 0.4;
}
.page-info {
font-size: 13px;
color: #999999;
color: #999;
margin-left: 8px;
}
</style>
+302 -54
查看文件
@@ -1,94 +1,342 @@
<template>
<view class="container">
<view class="card">
<view class="card-title">系统设置</view>
<AdminLayout currentPage="/pages/settings/index">
<view class="page-card">
<view class="card-title"> 系统设置</view>
<view class="setting-section">
<view class="section-label">管理员管理</view>
<view class="info-row">
<text>当前仅支持通过数据库管理管理员账号</text>
<view class="section-card">
<view class="section-title">基础配置</view>
<view class="form-grid">
<view class="form-group">
<text class="form-label">系统名称</text>
<input class="form-input" v-model="settings.system_name" placeholder="系统名称" />
</view>
<view class="form-group">
<text class="form-label">管理员邮箱</text>
<input class="form-input" v-model="settings.admin_email" placeholder="管理员邮箱" />
</view>
<view class="form-group">
<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="divider"></view>
<view class="section-card">
<view class="section-title">订阅配置</view>
<view class="form-grid">
<view class="form-group">
<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-group">
<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-group">
<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="setting-section">
<view class="section-label">环境变量</view>
<view class="info-row">
<text class="info-label">API地址</text>
<text>https://api.lightmask.com</text>
</view>
<view class="info-row">
<text class="info-label">MQTT Broker</text>
<text>mqtt://iotcloud.tencent.com:8883</text>
</view>
<view class="info-row">
<text class="info-label">JWT有效期</text>
<text>7</text>
<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="card">
<view class="card-title">关于</view>
<view class="info-row">
<text class="info-label">系统版本</text>
<text>1.0.0</text>
</view>
<view class="info-row">
<text class="info-label">协议版本</text>
<text>v1.0</text>
</view>
<view class="save-bar">
<button class="btn-primary" @click="onSave">保存设置</button>
<button class="btn-default" @click="onReset">重置</button>
</view>
</view>
</AdminLayout>
</template>
<script>
export default {}
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']
}
},
onShow() {
this.loadSettings()
},
methods: {
async loadSettings() {
try {
const data = await get('/api/v1/admin/settings')
if (data) {
Object.assign(this.settings, data)
}
} catch (e) {}
},
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]
}
}
}
</script>
<style scoped>
.container {
padding: 16px;
}
.card {
background: #ffffff;
.page-card {
background: #fff;
border-radius: 8px;
padding: 16px;
padding: 20px;
margin-bottom: 16px;
}
.card-title {
font-size: 16px;
font-size: 18px;
font-weight: 600;
color: #333;
margin-bottom: 20px;
}
.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;
}
.setting-section {
margin-bottom: 16px;
.form-grid {
display: grid;
grid-template-columns: 1fr 1fr 1fr;
gap: 16px;
}
.section-label {
.form-group {
display: flex;
flex-direction: column;
gap: 6px;
}
.form-label {
font-size: 13px;
color: #666;
}
.form-input {
height: 36px;
border: 1px solid #d9d9d9;
border-radius: 6px;
padding: 0 12px;
font-size: 14px;
font-weight: 500;
margin-bottom: 12px;
box-sizing: border-box;
color: #333;
}
.info-row {
.input-suffix {
display: flex;
align-items: center;
gap: 8px;
}
.input-suffix .form-input {
flex: 1;
}
.suffix {
font-size: 14px;
color: #999;
white-space: nowrap;
}
.select-wrap {
position: relative;
}
.picker-input {
display: flex;
justify-content: space-between;
padding: 8px 0;
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;
}
.info-label {
color: #999999;
.toggle-desc {
font-size: 12px;
color: #999;
}
.divider {
height: 1px;
background: #eeeeee;
margin: 16px 0;
.toggle-switch {
width: 44px;
height: 24px;
border-radius: 12px;
background: #d9d9d9;
position: relative;
cursor: pointer;
transition: background 0.3s;
}
.toggle-switch.on {
background: #E6508C;
}
.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;
}
</style>
+362 -74
查看文件
@@ -1,76 +1,123 @@
<template>
<view class="container">
<view class="toolbar flex-between">
<text class="total-text"> {{ total }} </text>
<u-button type="primary" size="small" @click="showCreate = true">创建订阅</u-button>
</view>
<view class="table-wrap">
<u-table>
<u-tr>
<u-th>用户ID</u-th>
<u-th>昵称</u-th>
<u-th>类型</u-th>
<u-th>方案</u-th>
<u-th>状态</u-th>
<u-th>到期时间</u-th>
</u-tr>
<u-tr v-for="item in subscriptions" :key="item.id">
<u-td>{{ item.user_id }}</u-td>
<u-td>{{ item.nickname || '-' }}</u-td>
<u-td>{{ subTypeMap[item.type] || '-' }}</u-td>
<u-td>{{ item.plan || '-' }}</u-td>
<u-td>
<text :class="item.status === 1 ? 'text-success' : 'text-muted'">
{{ subStatusMap[item.status] || '-' }}
</text>
</u-td>
<u-td>{{ formatDate(item.expired_at) }}</u-td>
</u-tr>
</u-table>
</view>
<view class="pagination">
<u-button size="small" :disabled="page <= 1" @click="onPage(page - 1)">上一页</u-button>
<text class="page-info"> {{ page }} / {{ totalPages }} </text>
<u-button size="small" :disabled="page >= totalPages" @click="onPage(page + 1)">下一页</u-button>
</view>
<u-popup :show="showCreate" @close="showCreate = false" mode="center">
<view class="create-form">
<view class="form-title">创建订阅</view>
<u-form :model="createForm">
<u-form-item label="用户ID">
<u-input v-model="createForm.user_id" placeholder="输入用户ID" />
</u-form-item>
<u-form-item label="方案">
<u-input v-model="createForm.plan" placeholder="monthly/quarterly/yearly" />
</u-form-item>
<u-form-item label="天数">
<u-input v-model="createForm.days" placeholder="订阅天数" type="number" />
</u-form-item>
</u-form>
<u-button type="primary" @click="onCreate" :loading="creating">创建</u-button>
<AdminLayout currentPage="/pages/subscription/index">
<view class="page-card">
<view class="page-header">
<text class="page-title">💳 订阅管理</text>
<view class="header-actions">
<button class="btn-default btn-sm">导出报表</button>
</view>
</view>
</u-popup>
</view>
<view class="stats-row">
<view class="stat-item green">
<text class="stat-value">{{ stats.monthly_count || 486 }}</text>
<text class="stat-label">月卡会员</text>
</view>
<view class="stat-item green">
<text class="stat-value">{{ stats.yearly_count || 1258 }}</text>
<text class="stat-label">年卡会员</text>
</view>
<view class="stat-item gold">
<text class="stat-value">{{ stats.trial_count || 856 }}</text>
<text class="stat-label">试用中</text>
</view>
<view class="stat-item">
<text class="stat-value">¥{{ stats.monthly_revenue || 45890 }}</text>
<text class="stat-label">本月收入</text>
</view>
</view>
</view>
<view class="page-card">
<view class="tabs">
<view class="tab active">全部订阅</view>
<view class="tab" @click="onTabFilter('monthly')">月卡</view>
<view class="tab" @click="onTabFilter('yearly')">年卡</view>
<view class="tab" @click="onTabFilter('trial')">试用中</view>
<view class="tab" @click="onTabFilter('expired')">已过期</view>
</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 flex1">订单金额</text>
<text class="t-th flex2">开始日期</text>
<text class="t-th flex2">到期日期</text>
<text class="t-th flex1">状态</text>
<text class="t-th flex1">操作</text>
</view>
</view>
<view class="t-body">
<view class="t-row" v-for="item in subscriptions" :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.started_at) }}</text>
<text class="t-td flex2">{{ formatDate(item.expired_at) }}</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">详情</text>
<text class="action-link" v-if="item.status === 1">延期</text>
<text class="action-link" v-if="item.status === 2">开通</text>
<text class="action-link" v-if="item.status === 3">续费</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 class="modal-mask" v-if="showCreate" @click="showCreate = false">
<view class="modal-content" @click.stop>
<view class="modal-title">创建订阅</view>
<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>
<view class="modal-actions">
<button class="btn-default" @click="showCreate = false">取消</button>
<button class="btn-primary" @click="onCreate" :loading="creating">创建</button>
</view>
</view>
</view>
</AdminLayout>
</template>
<script>
import { get, post } from '../../utils/request'
import AdminLayout from '../../components/AdminLayout.vue'
export default {
components: { AdminLayout },
data() {
return {
subscriptions: [],
total: 0,
page: 1,
pageSize: 20,
stats: {},
showCreate: false,
creating: false,
createForm: { user_id: '', plan: 'monthly', days: 30 },
subTypeMap: { 1: '试用', 2: '正式', 3: '赠送' },
subStatusMap: { 1: '有效', 2: '已过期', 3: '已停用' }
activeTab: 'all'
}
},
computed: {
@@ -84,11 +131,17 @@ export default {
methods: {
async loadSubscriptions() {
try {
const data = await get('/api/v1/admin/subscriptions', { page: this.page, page_size: this.pageSize })
const data = await get('/api/v1/admin/subscriptions', { page: this.page, page_size: this.pageSize, tab: this.activeTab })
this.subscriptions = data.records || []
this.total = data.total || 0
if (data.stats) this.stats = data.stats
} catch (e) {}
},
onTabFilter(tab) {
this.activeTab = tab
this.page = 1
this.loadSubscriptions()
},
onPage(p) {
this.page = p
this.loadSubscriptions()
@@ -110,6 +163,18 @@ export default {
this.creating = false
}
},
planText(plan) {
const map = { monthly: '月卡', quarterly: '季卡', yearly: '年卡', trial: '试用' }
return map[plan] || plan || '-'
},
statusText(status) {
const map = { 1: '生效中', 2: '试用中', 3: '已过期' }
return map[status] || '-'
},
statusBadge(status) {
const map = { 1: 'badge badge-success', 2: 'badge badge-warning', 3: 'badge badge-default' }
return map[status] || 'badge badge-default'
},
formatDate(d) {
return d ? d.slice(0, 10) : '-'
}
@@ -118,48 +183,271 @@ export default {
</script>
<style scoped>
.container {
padding: 16px;
}
.toolbar {
.page-card {
background: #fff;
border-radius: 8px;
padding: 20px;
margin-bottom: 16px;
}
.total-text {
.page-header {
display: flex;
justify-content: space-between;
align-items: center;
margin-bottom: 20px;
}
.page-title {
font-size: 18px;
font-weight: 600;
color: #333;
}
.header-actions {
display: flex;
align-items: center;
gap: 8px;
}
.stats-row {
display: flex;
gap: 16px;
margin-bottom: 4px;
}
.stat-item {
flex: 1;
background: #fafafa;
border-radius: 8px;
padding: 16px;
text-align: center;
}
.stat-item.green {
background: #f6ffed;
}
.stat-item.gold {
background: #fffbe6;
}
.stat-value {
display: block;
font-size: 22px;
font-weight: 700;
color: #333;
margin-bottom: 4px;
}
.green .stat-value {
color: #52c41a;
}
.gold .stat-value {
color: #faad14;
}
.stat-label {
font-size: 13px;
color: #999999;
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;
}
.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;
}
.flex1 {
flex: 1;
}
.flex2 {
flex: 2;
}
.badge {
display: inline-block;
padding: 2px 8px;
border-radius: 4px;
font-size: 12px;
}
.badge-success {
background: #f6ffed;
color: #52c41a;
}
.badge-warning {
background: #fffbe6;
color: #faad14;
}
.badge-default {
background: #f5f5f5;
color: #999;
}
.action-link {
color: #E6508C;
font-size: 14px;
margin-right: 8px;
cursor: pointer;
}
.pagination {
display: flex;
align-items: center;
justify-content: center;
gap: 16px;
margin-top: 16px;
gap: 8px;
margin-top: 20px;
}
.btn-page {
width: 32px;
height: 32px;
border: 1px solid #d9d9d9;
border-radius: 6px;
background: #fff;
color: #333;
font-size: 14px;
display: flex;
align-items: center;
justify-content: center;
cursor: pointer;
}
.btn-page.active {
background: #E6508C;
color: #fff;
border-color: #E6508C;
}
.btn-page[disabled] {
opacity: 0.4;
}
.page-info {
font-size: 13px;
color: #999999;
color: #999;
margin-left: 8px;
}
.create-form {
.btn-primary {
background: #E6508C;
color: #fff;
border: none;
border-radius: 6px;
cursor: pointer;
}
.btn-default {
background: #fff;
color: #333;
border: 1px solid #d9d9d9;
border-radius: 6px;
cursor: pointer;
}
.btn-sm {
height: 32px;
padding: 0 16px;
font-size: 14px;
line-height: 32px;
}
.modal-mask {
position: fixed;
top: 0;
left: 0;
right: 0;
bottom: 0;
background: rgba(0, 0, 0, 0.45);
display: flex;
align-items: center;
justify-content: center;
z-index: 1000;
}
.modal-content {
width: 440px;
background: #fff;
border-radius: 8px;
padding: 24px;
width: 400px;
}
.form-title {
.modal-title {
font-size: 18px;
font-weight: 600;
margin-bottom: 20px;
}
.form-group {
margin-bottom: 16px;
}
.text-success {
color: #52c41a;
.form-label {
display: block;
font-size: 14px;
color: #333;
margin-bottom: 6px;
}
.text-muted {
color: #999999;
.form-input {
width: 100%;
height: 36px;
border: 1px solid #d9d9d9;
border-radius: 6px;
padding: 0 12px;
font-size: 14px;
box-sizing: border-box;
}
.modal-actions {
display: flex;
justify-content: flex-end;
gap: 8px;
margin-top: 20px;
}
</style>
+258 -82
查看文件
@@ -1,91 +1,95 @@
<template>
<view class="container">
<view class="card" v-if="user">
<view class="card-title">用户信息</view>
<view class="info-row">
<text class="info-label">用户ID</text>
<text>{{ user.user_id }}</text>
<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="info-row">
<text class="info-label">昵称</text>
<text>{{ user.nickname || '-' }}</text>
<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="info-row">
<text class="info-label">手机号</text>
<text>{{ user.phone || '-' }}</text>
</view>
<view class="info-row">
<text class="info-label">注册时间</text>
<text>{{ formatDate(user.created_at) }}</text>
<view class="stats-row">
<view class="stat-item pink-bg">
<text class="stat-value">{{ user.treatment_count || 0 }}</text>
<text class="stat-label">护理次数</text>
</view>
<view class="stat-item">
<text class="stat-value">{{ user.total_duration || '0h' }}</text>
<text class="stat-label">累计时长</text>
</view>
<view class="stat-item">
<text class="stat-value">¥{{ user.total_spent || 0 }}</text>
<text class="stat-label">累计消费</text>
</view>
</view>
</view>
<view class="card" v-if="user && user.devices">
<view class="card-title">绑定设备</view>
<view v-for="(d, idx) in user.devices" :key="idx" class="device-item">
<view class="info-row">
<text class="info-label">设备</text>
<text>{{ d.device_name || d.device_id }}</text>
<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-row">
<text class="info-label">绑定时间</text>
<text>{{ formatDate(d.bound_at) }}</text>
<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 class="divider" v-if="idx < user.devices.length - 1"></view>
</view>
<view v-if="user.devices.length === 0" class="text-muted">暂无绑定设备</view>
</view>
<view class="card" v-if="user && user.subscriptions">
<view class="card-title">订阅记录</view>
<view v-for="(s, idx) in user.subscriptions" :key="idx">
<view class="info-row">
<text class="info-label">类型</text>
<text>{{ subTypeMap[s.type] || '未知' }}</text>
<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="info-row">
<text class="info-label">状态</text>
<text :class="s.status === 1 ? 'text-success' : 'text-muted'">
{{ subStatusMap[s.status] || '未知' }}
</text>
<view class="t-body">
<view class="t-row" v-for="(t, idx) in user.recent_treatments" :key="idx">
<text class="t-td flex2">{{ formatDate(t.started_at) }}</text>
<text class="t-td flex1">{{ Math.floor((t.total_duration_ms || 0) / 60000) }}分钟</text>
<text class="t-td flex2">{{ t.device_id || '-' }}</text>
</view>
</view>
<view class="info-row">
<text class="info-label">有效期</text>
<text>{{ formatDate(s.started_at) }} ~ {{ formatDate(s.expired_at) }}</text>
</view>
<view class="divider" v-if="idx < user.subscriptions.length - 1"></view>
</view>
<view v-if="user.subscriptions.length === 0" class="text-muted">暂无订阅</view>
</view>
<view class="card" v-if="user && user.recent_treatments">
<view class="card-title">最近护理</view>
<view v-for="(t, idx) in user.recent_treatments" :key="idx">
<view class="info-row">
<text class="info-label">时间</text>
<text>{{ formatDate(t.started_at) }}</text>
</view>
<view class="info-row">
<text class="info-label">时长</text>
<text>{{ Math.floor((t.total_duration_ms || 0) / 60000) }}分钟</text>
</view>
<view class="divider" v-if="idx < user.recent_treatments.length - 1"></view>
<view class="view-all" v-if="user.treatment_count > 5">
<text class="view-all-link" @click="goRecords">查看全部{{ user.treatment_count }}条记录 </text>
</view>
<view v-if="user.recent_treatments.length === 0" class="text-muted">暂无记录</view>
</view>
</view>
</AdminLayout>
</template>
<script>
import { get } from '../../utils/request'
import AdminLayout from '../../components/AdminLayout.vue'
export default {
components: { AdminLayout },
data() {
return {
user: null,
userId: '',
subTypeMap: { 1: '试用', 2: '正式', 3: '赠送' },
subStatusMap: { 1: '有效', 2: '已过期', 3: '已停用' }
userId: ''
}
},
onLoad(options) {
@@ -100,6 +104,20 @@ export default {
uni.showToast({ title: '加载失败', icon: 'none' })
}
},
goBack() {
uni.navigateBack()
},
goRecords() {
uni.navigateTo({ url: '/pages/record/index?user_id=' + this.userId })
},
subStatusText(status) {
const map = { yearly: '年卡会员', monthly: '月卡会员', trial: '试用中', none: '未订阅' }
return map[status] || '未订阅'
},
subTypeText(type) {
const map = { yearly: '年卡', monthly: '月卡', trial: '试用' }
return map[type] || '-'
},
formatDate(d) {
return d ? d.slice(0, 10) : '-'
}
@@ -108,45 +126,203 @@ export default {
</script>
<style scoped>
.container {
padding: 16px;
.back-link {
color: #E6508C;
font-size: 14px;
margin-bottom: 16px;
cursor: pointer;
}
.card {
background: #ffffff;
.page-card {
background: #fff;
border-radius: 8px;
padding: 20px;
margin-bottom: 16px;
}
.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 {
display: inline-block;
padding: 2px 8px;
border-radius: 4px;
font-size: 12px;
margin-top: 4px;
}
.badge-success {
background: #f6ffed;
color: #52c41a;
}
.stats-row {
display: flex;
gap: 16px;
}
.stat-item {
flex: 1;
background: #fafafa;
border-radius: 8px;
padding: 16px;
margin-bottom: 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;
margin-bottom: 12px;
color: #333;
margin-bottom: 16px;
}
.info-row {
.info-grid {
display: grid;
grid-template-columns: 1fr 1fr;
gap: 16px;
}
.info-item {
display: flex;
justify-content: space-between;
padding: 8px 0;
font-size: 14px;
flex-direction: column;
gap: 6px;
}
.info-label {
color: #999999;
font-size: 13px;
color: #999;
}
.divider {
height: 1px;
background: #eeeeee;
margin: 8px 0;
.info-value {
font-size: 14px;
color: #333;
}
.text-success {
color: #52c41a;
.data-table {
width: 100%;
}
.text-muted {
color: #999999;
.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;
}
.flex1 {
flex: 1;
}
.flex2 {
flex: 2;
}
.view-all {
text-align: center;
margin-top: 16px;
}
.view-all-link {
color: #E6508C;
font-size: 14px;
cursor: pointer;
}
.btn-primary {
background: #E6508C;
color: #fff;
border: none;
border-radius: 6px;
cursor: pointer;
}
.btn-sm {
height: 32px;
padding: 0 16px;
font-size: 14px;
line-height: 32px;
}
</style>
+217 -35
查看文件
@@ -1,40 +1,67 @@
<template>
<view class="container">
<view class="toolbar">
<u-search v-model="keyword" placeholder="搜索用户昵称" @search="onSearch" @clear="onSearch" />
</view>
<AdminLayout currentPage="/pages/user/index">
<view class="page-card">
<view class="page-header">
<text class="page-title">👥 用户管理</text>
<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">导出用户</button>
</view>
</view>
<view class="table-wrap">
<u-table>
<u-tr>
<u-th>用户ID</u-th>
<u-th>昵称</u-th>
<u-th>设备</u-th>
<u-th>订阅到期</u-th>
<u-th>注册时间</u-th>
</u-tr>
<u-tr v-for="item in users" :key="item.user_id" @click="onDetail(item.user_id)">
<u-td>{{ item.user_id }}</u-td>
<u-td>{{ item.nickname || '-' }}</u-td>
<u-td>{{ item.device_count || 0 }}</u-td>
<u-td>{{ formatDate(item.subscription_expire) }}</u-td>
<u-td>{{ formatDate(item.created_at) }}</u-td>
</u-tr>
</u-table>
</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 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">👤 {{ item.nickname || '-' }}</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="onDetail(item.user_id)">记录</text>
</text>
</view>
</view>
</view>
<view class="pagination">
<u-button size="small" :disabled="page <= 1" @click="onPage(page - 1)">上一页</u-button>
<text class="page-info"> {{ page }} / {{ totalPages }} </text>
<u-button size="small" :disabled="page >= totalPages" @click="onPage(page + 1)">下一页</u-button>
<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>
</AdminLayout>
</template>
<script>
import { get } from '../../utils/request'
import AdminLayout from '../../components/AdminLayout.vue'
export default {
components: { AdminLayout },
data() {
return {
users: [],
@@ -55,7 +82,7 @@ export default {
methods: {
async loadUsers() {
try {
const data = await get('/api/v1/admin/users', { page: this.page, page_size: this.pageSize })
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) {}
@@ -71,6 +98,19 @@ export default {
onDetail(userId) {
uni.navigateTo({ url: '/pages/user-detail/index?user_id=' + userId })
},
subStatusText(status) {
const map = { yearly: '年卡', monthly: '月卡', trial: '试用中', none: '未订阅' }
return map[status] || '未订阅'
},
subBadge(status) {
const map = {
yearly: 'badge badge-success',
monthly: 'badge badge-success',
trial: 'badge badge-warning',
none: 'badge badge-default'
}
return map[status] || 'badge badge-default'
},
formatDate(d) {
return d ? d.slice(0, 10) : '-'
}
@@ -79,24 +119,166 @@ export default {
</script>
<style scoped>
.container {
padding: 16px;
.page-card {
background: #fff;
border-radius: 8px;
padding: 20px;
}
.toolbar {
margin-bottom: 16px;
.page-header {
display: flex;
justify-content: space-between;
align-items: center;
margin-bottom: 20px;
}
.page-title {
font-size: 18px;
font-weight: 600;
color: #333;
}
.header-actions {
display: flex;
align-items: center;
gap: 8px;
}
.search-input {
width: 220px;
height: 32px;
border: 1px solid #d9d9d9;
border-radius: 6px;
padding: 0 12px;
font-size: 14px;
box-sizing: border-box;
}
.input-placeholder {
color: #bfbfbf;
}
.btn-primary {
background: #E6508C;
color: #fff;
border: none;
border-radius: 6px;
cursor: pointer;
}
.btn-default {
background: #fff;
color: #333;
border: 1px solid #d9d9d9;
border-radius: 6px;
cursor: pointer;
}
.btn-sm {
height: 32px;
padding: 0 16px;
font-size: 14px;
line-height: 32px;
}
.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;
}
.flex1 {
flex: 1;
}
.flex2 {
flex: 2;
}
.badge {
display: inline-block;
padding: 2px 8px;
border-radius: 4px;
font-size: 12px;
}
.badge-success {
background: #f6ffed;
color: #52c41a;
}
.badge-warning {
background: #fffbe6;
color: #faad14;
}
.badge-default {
background: #f5f5f5;
color: #999;
}
.action-link {
color: #E6508C;
font-size: 14px;
margin-right: 12px;
cursor: pointer;
}
.pagination {
display: flex;
align-items: center;
justify-content: center;
gap: 16px;
margin-top: 16px;
gap: 8px;
margin-top: 20px;
}
.btn-page {
width: 32px;
height: 32px;
border: 1px solid #d9d9d9;
border-radius: 6px;
background: #fff;
color: #333;
font-size: 14px;
display: flex;
align-items: center;
justify-content: center;
cursor: pointer;
}
.btn-page.active {
background: #E6508C;
color: #fff;
border-color: #E6508C;
}
.btn-page[disabled] {
opacity: 0.4;
}
.page-info {
font-size: 13px;
color: #999999;
color: #999;
margin-left: 8px;
}
</style>