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 行删除
+16 -16
查看文件
@@ -6,10 +6,10 @@
<view class="sidebar-menu">
<view
v-for="item in menuItems"
:key="item.path"
:key="item.view"
class="sidebar-item"
:class="{ active: currentPage === item.path }"
@click="navigate(item.path)"
:class="{ active: currentPage === item.view }"
@click="navigate(item.view)"
>
<text class="sidebar-icon">{{ item.icon }}</text>
<text>{{ item.label }}</text>
@@ -41,32 +41,32 @@ export 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' }
{ icon: '📊', label: '仪表盘', view: 'dashboard' },
{ icon: '📱', label: '设备管理', view: 'device' },
{ icon: '👥', label: '用户管理', view: 'user' },
{ icon: '💳', label: '订阅管理', view: 'subscription' },
{ icon: '📋', label: '护理记录', view: 'record' },
{ icon: '📝', label: '操作日志', view: 'log' },
{ icon: '⚙️', label: '系统设置', view: 'settings' }
]
}
},
computed: {
pageTitle() {
const item = this.menuItems.find(m => m.path === this.currentPage)
return item ? `${item.icon} ${item.label}` : '光子美容仪管理后台'
var item = this.menuItems.find(m => m.view === this.currentPage)
return item ? item.icon + ' ' + item.label : '光子美容仪管理后台'
},
adminName() {
const store = useUserStore()
var store = useUserStore()
return (store.adminInfo && store.adminInfo.real_name) || '管理员'
}
},
methods: {
navigate(path) {
uni.redirectTo({ url: path })
navigate(view) {
this.$emit('navigate', view)
},
onLogout() {
const store = useUserStore()
var store = useUserStore()
store.clearToken()
uni.reLaunch({ url: '/pages/login/index' })
}
+2 -34
查看文件
@@ -5,40 +5,8 @@
"style": { "navigationBarTitleText": "管理员登录", "navigationStyle": "custom" }
},
{
"path": "pages/dashboard/index",
"style": { "navigationBarTitleText": "仪表盘" }
},
{
"path": "pages/device/index",
"style": { "navigationBarTitleText": "设备管理" }
},
{
"path": "pages/device-detail/index",
"style": { "navigationBarTitleText": "设备详情" }
},
{
"path": "pages/user/index",
"style": { "navigationBarTitleText": "用户管理" }
},
{
"path": "pages/user-detail/index",
"style": { "navigationBarTitleText": "用户详情" }
},
{
"path": "pages/subscription/index",
"style": { "navigationBarTitleText": "订阅管理" }
},
{
"path": "pages/record/index",
"style": { "navigationBarTitleText": "护理记录" }
},
{
"path": "pages/log/index",
"style": { "navigationBarTitleText": "操作日志" }
},
{
"path": "pages/settings/index",
"style": { "navigationBarTitleText": "系统设置" }
"path": "pages/admin/index",
"style": { "navigationBarTitleText": "管理后台", "navigationStyle": "custom" }
}
],
"globalStyle": {
+78
查看文件
@@ -0,0 +1,78 @@
<template>
<AdminLayout :currentPage="currentView" @navigate="onNavigate">
<keep-alive :include="keepAliveViews">
<component :is="currentComponent" :key="viewKey" v-bind="viewProps" @navigate="onNavigate" />
</keep-alive>
</AdminLayout>
</template>
<script>
import AdminLayout from '../../components/AdminLayout.vue'
import DashboardView from '../../views/DashboardView.vue'
import DeviceListView from '../../views/DeviceListView.vue'
import DeviceDetailView from '../../views/DeviceDetailView.vue'
import UserListView from '../../views/UserListView.vue'
import UserDetailView from '../../views/UserDetailView.vue'
import SubscriptionView from '../../views/SubscriptionView.vue'
import RecordView from '../../views/RecordView.vue'
import LogView from '../../views/LogView.vue'
import SettingsView from '../../views/SettingsView.vue'
var VIEW_MAP = {
dashboard: DashboardView,
device: DeviceListView,
'device-detail': DeviceDetailView,
user: UserListView,
'user-detail': UserDetailView,
subscription: SubscriptionView,
record: RecordView,
log: LogView,
settings: SettingsView
}
// List views that should be kept alive (preserve state when navigating to detail and back)
var KEEP_ALIVE_VIEWS = ['DeviceListView', 'UserListView', 'SubscriptionView', 'RecordView', 'LogView']
export default {
components: {
AdminLayout,
DashboardView,
DeviceListView,
DeviceDetailView,
UserListView,
UserDetailView,
SubscriptionView,
RecordView,
LogView,
SettingsView
},
data() {
return {
currentView: 'dashboard',
viewProps: {},
keepAliveViews: KEEP_ALIVE_VIEWS
}
},
computed: {
currentComponent() {
return VIEW_MAP[this.currentView] || DashboardView
},
viewKey() {
// Detail views get a unique key so they always re-mount with fresh data
if (this.currentView === 'device-detail') {
return 'device-detail-' + (this.viewProps.device_id || '')
}
if (this.currentView === 'user-detail') {
return 'user-detail-' + (this.viewProps.user_id || '')
}
return this.currentView
}
},
methods: {
onNavigate(view, props) {
this.currentView = view
this.viewProps = props || {}
}
}
}
</script>
+1 -1
查看文件
@@ -57,7 +57,7 @@ export default {
try {
const userStore = useUserStore()
await userStore.login(this.form)
uni.reLaunch({ url: '/pages/dashboard/index' })
uni.reLaunch({ url: '/pages/admin/index' })
} catch (e) {
uni.showToast({ title: e.message || '登录失败', icon: 'none' })
} finally {
@@ -1,5 +1,5 @@
<template>
<AdminLayout currentPage="/pages/dashboard/index">
<view>
<view class="stat-grid">
<view class="stat-card">
<view class="stat-icon blue">📱</view>
@@ -46,7 +46,7 @@
<view class="card table-card">
<view class="card-header">
<text class="card-title">实时护理数据</text>
<text class="card-extra" @click="onNav('/pages/record/index')">查看全部 </text>
<text class="card-extra" @click="$emit('navigate', 'record')">查看全部 </text>
</view>
<view class="data-table">
<view class="t-header">
@@ -82,22 +82,22 @@
<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-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="onNav('/pages/user/index')">
<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="onNav('/pages/subscription/index')">
<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="onNav('/pages/record/index')">
<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>
@@ -105,16 +105,14 @@
</view>
</view>
</view>
</AdminLayout>
</view>
</template>
<script>
import { get } from '../../utils/request'
import { formatDate as formatDateUtil } from '../../utils/format'
import AdminLayout from '../../components/AdminLayout.vue'
import { get } from '../utils/request'
import { formatDate as formatDateUtil } from '../utils/format'
export default {
components: { AdminLayout },
data() {
return {
stats: {},
@@ -122,7 +120,7 @@ export default {
recentTreatments: []
}
},
onShow() {
mounted() {
this.loadDashboard()
},
methods: {
@@ -141,10 +139,7 @@ export default {
this.recentTreatments = []
}
},
formatDate: formatDateUtil,
onNav(path) {
uni.redirectTo({ url: path })
}
formatDate: formatDateUtil
}
}
</script>
@@ -1,6 +1,6 @@
<template>
<AdminLayout currentPage="/pages/device/index">
<view class="back-link" @click="goBack"> 返回设备列表</view>
<view>
<view class="back-link" @click="$emit('navigate', 'device')"> 返回设备列表</view>
<view class="page-card">
<view class="page-header">
@@ -97,16 +97,17 @@
<button class="btn-primary" @click="onCommand(4)">查询状态</button>
<button class="btn-default" @click="onViewLogs">查看完整日志</button>
</view>
</AdminLayout>
</view>
</template>
<script>
import { get, post } from '../../utils/request'
import { formatDate as formatDateUtil } from '../../utils/format'
import AdminLayout from '../../components/AdminLayout.vue'
import { get, post } from '../utils/request'
import { formatDate as formatDateUtil } from '../utils/format'
export default {
components: { AdminLayout },
props: {
device_id: { type: String, default: '' }
},
data() {
return {
device: null,
@@ -114,9 +115,16 @@ export default {
statusMap: { 1: '未激活', 2: '在线', 3: '离线', 4: '故障' }
}
},
onLoad(options) {
this.deviceId = options.device_id || ''
this.loadDevice()
watch: {
device_id: {
immediate: true,
handler(val) {
if (val) {
this.deviceId = val
this.loadDevice()
}
}
}
},
methods: {
async loadDevice() {
@@ -126,9 +134,6 @@ 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 })
@@ -155,7 +160,7 @@ export default {
})
},
onViewLogs() {
uni.redirectTo({ url: '/pages/log/index?device_id=' + this.deviceId })
this.$emit('navigate', 'log', { device_id: this.deviceId })
},
onCheckUpdate() {
uni.showToast({ title: '正在检查更新...', icon: 'none' })
@@ -166,7 +171,7 @@ export default {
</script>
<style scoped>
@import '../../styles/common.css';
@import '../styles/common.css';
.back-link {
color: #E6508C;
@@ -1,5 +1,5 @@
<template>
<AdminLayout currentPage="/pages/device/index">
<view>
<view class="toolbar">
<view class="header-actions">
<input
@@ -56,23 +56,23 @@
</view>
<view class="batch-hint">最多 500 个设备</view>
</ConfirmModal>
</AdminLayout>
</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 AdminLayout from '../../components/AdminLayout.vue'
import DataTable from '../../components/DataTable.vue'
import ConfirmModal from '../../components/ConfirmModal.vue'
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 {
components: { AdminLayout, DataTable, ConfirmModal },
name: 'DeviceListView',
components: { DataTable, ConfirmModal },
mixins: [listMixin('/api/v1/admin/devices')],
data() {
return {
@@ -93,7 +93,7 @@ export default {
batchImporting: false
}
},
onShow() {
mounted() {
this.reload()
},
methods: {
@@ -101,7 +101,7 @@ export default {
this.loadList({ keyword: this.keyword })
},
onDetail(deviceId) {
uni.navigateTo({ url: '/pages/device-detail/index?device_id=' + deviceId })
this.$emit('navigate', 'device-detail', { device_id: deviceId })
},
onCreateDevice() {
var self = this
@@ -186,7 +186,7 @@ export default {
</script>
<style scoped>
@import '../../styles/common.css';
@import '../styles/common.css';
.batch-hint {
font-size: 12px;
@@ -1,5 +1,5 @@
<template>
<AdminLayout currentPage="/pages/log/index">
<view>
<view class="toolbar">
<view class="header-actions">
<input
@@ -35,17 +35,19 @@
<text class="page-info"> {{ totalPages }} </text>
</view>
</view>
</AdminLayout>
</view>
</template>
<script>
import { get } from '../../utils/request'
import { exportCSV } from '../../utils/export'
import { formatDate as formatDateUtil } from '../../utils/format'
import AdminLayout from '../../components/AdminLayout.vue'
import { get } from '../utils/request'
import { exportCSV } from '../utils/export'
import { formatDate as formatDateUtil } from '../utils/format'
export default {
components: { AdminLayout },
name: 'LogView',
props: {
device_id: { type: String, default: '' }
},
data() {
return {
logs: [],
@@ -60,14 +62,15 @@ export default {
return Math.ceil(this.total / this.pageSize) || 1
}
},
onLoad(options) {
if (options && options.device_id) {
this.filters.device_id = options.device_id
watch: {
device_id: {
immediate: true,
handler(val) {
this.filters.device_id = val || ''
this.loadLogs()
}
}
},
onShow() {
this.loadLogs()
},
methods: {
async loadLogs() {
try {
@@ -131,7 +134,7 @@ export default {
</script>
<style scoped>
@import '../../styles/common.css';
@import '../styles/common.css';
.log-list {
margin-top: 8px;
@@ -1,5 +1,5 @@
<template>
<AdminLayout currentPage="/pages/record/index">
<view>
<view class="toolbar">
<view class="header-actions">
<input class="search-input date-input" v-model="dateFrom" placeholder="开始日期" />
@@ -58,21 +58,23 @@
<text class="page-info"> {{ totalPages }} </text>
</view>
</view>
</AdminLayout>
</view>
</template>
<script>
import { get } from '../../utils/request'
import { exportCSV } from '../../utils/export'
import { formatDate as formatDateUtil } from '../../utils/format'
import AdminLayout from '../../components/AdminLayout.vue'
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 {
components: { AdminLayout },
name: 'RecordView',
props: {
user_id: { type: String, default: '' }
},
data() {
return {
records: [],
@@ -89,8 +91,16 @@ export default {
return Math.ceil(this.total / this.pageSize) || 1
}
},
onShow() {
this.loadRecords()
watch: {
user_id: {
immediate: true,
handler(val) {
if (val) {
this.keyword = val
}
this.loadRecords()
}
}
},
methods: {
async loadRecords() {
@@ -147,7 +157,6 @@ export default {
async onExport() {
try {
const data = await get('/api/v1/admin/records', { page: 1, page_size: 9999, keyword: this.keyword, date_from: this.dateFrom, date_to: this.dateTo })
var self = this
var REGION_MAP = { 1: '左脸', 2: '右脸', 4: '额头', 8: '下巴', 16: '鼻', 32: '左眼', 64: '右眼' }
const records = data.records || []
exportCSV('records_' + new Date().toISOString().slice(0, 10) + '.csv',
@@ -169,7 +178,7 @@ export default {
</script>
<style scoped>
@import '../../styles/common.css';
@import '../styles/common.css';
.search-input {
width: 160px;
@@ -1,5 +1,5 @@
<template>
<AdminLayout currentPage="/pages/settings/index">
<view>
<view class="page-card">
<view class="section-card">
<view class="section-title">修改密码</view>
@@ -122,15 +122,13 @@
<button class="btn-primary" @click="onSave">保存设置</button>
<button class="btn-default" @click="onReset">重置</button>
</view>
</AdminLayout>
</view>
</template>
<script>
import { get, post } from '../../utils/request'
import AdminLayout from '../../components/AdminLayout.vue'
import { get, post } from '../utils/request'
export default {
components: { AdminLayout },
data() {
return {
settings: {
@@ -149,7 +147,7 @@ export default {
passwordForm: { old_password: '', new_password: '', confirm_password: '' }
}
},
onShow() {
mounted() {
this.loadSettings()
},
methods: {
@@ -1,5 +1,5 @@
<template>
<AdminLayout currentPage="/pages/subscription/index">
<view>
<view class="toolbar">
<view class="header-actions">
<button class="btn-primary btn-sm" @click="showCreate = true">创建订阅</button>
@@ -86,24 +86,24 @@
<input class="form-input" v-model="createForm.days" placeholder="订阅天数" type="number" />
</view>
</ConfirmModal>
</AdminLayout>
</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 AdminLayout from '../../components/AdminLayout.vue'
import DataTable from '../../components/DataTable.vue'
import ConfirmModal from '../../components/ConfirmModal.vue'
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 {
components: { AdminLayout, DataTable, ConfirmModal },
name: 'SubscriptionView',
components: { DataTable, ConfirmModal },
mixins: [listMixin('/api/v1/admin/subscriptions')],
data() {
return {
@@ -123,7 +123,7 @@ export default {
createForm: { user_id: '', plan: 'monthly', days: 30 }
}
},
onShow() {
mounted() {
this.reload()
},
methods: {
@@ -157,7 +157,7 @@ export default {
},
onDetail(item) {
if (item.user_id) {
uni.navigateTo({ url: '/pages/user-detail/index?user_id=' + item.user_id })
this.$emit('navigate', 'user-detail', { user_id: String(item.user_id) })
}
},
onExtend(item) {
@@ -232,7 +232,7 @@ export default {
</script>
<style scoped>
@import '../../styles/common.css';
@import '../styles/common.css';
.stats-row {
display: flex;
@@ -1,6 +1,6 @@
<template>
<AdminLayout currentPage="/pages/user/index">
<view class="back-link" @click="goBack"> 返回用户列表</view>
<view>
<view class="back-link" @click="$emit('navigate', 'user')"> 返回用户列表</view>
<view class="page-card" v-if="user">
<view class="page-header">
@@ -77,25 +77,33 @@
<text class="view-all-link" @click="goRecords">查看全部{{ user.treatment_count }}条记录 </text>
</view>
</view>
</AdminLayout>
</view>
</template>
<script>
import { get } from '../../utils/request'
import { formatDateShort } from '../../utils/format'
import AdminLayout from '../../components/AdminLayout.vue'
import { get } from '../utils/request'
import { formatDateShort } from '../utils/format'
export default {
components: { AdminLayout },
props: {
user_id: { type: String, default: '' }
},
data() {
return {
user: null,
userId: ''
}
},
onLoad(options) {
this.userId = options.user_id || ''
this.loadUser()
watch: {
user_id: {
immediate: true,
handler(val) {
if (val) {
this.userId = val
this.loadUser()
}
}
}
},
methods: {
async loadUser() {
@@ -105,11 +113,8 @@ export default {
uni.showToast({ title: '加载失败', icon: 'none' })
}
},
goBack() {
uni.navigateBack()
},
goRecords() {
uni.navigateTo({ url: '/pages/record/index?user_id=' + this.userId })
this.$emit('navigate', 'record', { user_id: this.userId })
},
subStatusText(status) {
return status === 1 ? '已订阅' : '未订阅'
@@ -124,7 +129,7 @@ export default {
</script>
<style scoped>
@import '../../styles/common.css';
@import '../styles/common.css';
.back-link {
color: #E6508C;
@@ -260,5 +265,4 @@ export default {
font-size: 14px;
cursor: pointer;
}
</style>
@@ -1,5 +1,5 @@
<template>
<AdminLayout currentPage="/pages/user/index">
<view>
<view class="toolbar">
<view class="header-actions">
<input
@@ -57,17 +57,16 @@
<text class="page-info"> {{ totalPages }} </text>
</view>
</view>
</AdminLayout>
</view>
</template>
<script>
import { get } from '../../utils/request'
import { exportCSV } from '../../utils/export'
import { formatDateShort } from '../../utils/format'
import AdminLayout from '../../components/AdminLayout.vue'
import { get } from '../utils/request'
import { exportCSV } from '../utils/export'
import { formatDateShort } from '../utils/format'
export default {
components: { AdminLayout },
name: 'UserListView',
data() {
return {
users: [],
@@ -82,7 +81,7 @@ export default {
return Math.ceil(this.total / this.pageSize) || 1
}
},
onShow() {
mounted() {
this.loadUsers()
},
methods: {
@@ -104,10 +103,10 @@ export default {
this.loadUsers()
},
onDetail(userId) {
uni.navigateTo({ url: '/pages/user-detail/index?user_id=' + userId })
this.$emit('navigate', 'user-detail', { user_id: userId })
},
onViewRecords(userId) {
uni.navigateTo({ url: '/pages/record/index?user_id=' + userId })
this.$emit('navigate', 'record', { user_id: userId })
},
subStatusText(status) {
return status === 1 ? '已订阅' : '未订阅'
@@ -136,7 +135,7 @@ export default {
</script>
<style scoped>
@import '../../styles/common.css';
@import '../styles/common.css';
.user-cell {
display: flex;