refactor: convert admin console to SPA with dynamic component switching
- Create shell page (pages/admin/index.vue) with AdminLayout + keep-alive - Convert 9 pages to view components (views/*.vue) - AdminLayout emits navigate events instead of uni.redirectTo - Sidebar navigation no longer causes full page reload - List views cached with keep-alive, detail views re-mount fresh - Fix: add name property to 5 cached views for keep-alive matching - Fix: add navigationStyle custom to prevent double nav bar - Fix: remove duplicate mounted() in RecordView/LogView
这个提交包含在:
@@ -0,0 +1,288 @@
|
||||
<template>
|
||||
<view>
|
||||
<view class="toolbar">
|
||||
<view class="header-actions">
|
||||
<button class="btn-primary btn-sm" @click="showCreate = true">创建订阅</button>
|
||||
<button class="btn-default btn-sm" @click="onExport">导出报表</button>
|
||||
</view>
|
||||
</view>
|
||||
|
||||
<view class="page-card">
|
||||
<view class="stats-row">
|
||||
<view class="stat-item">
|
||||
<text class="stat-value">{{ stats.monthly_count ?? 0 }}</text>
|
||||
<text class="stat-label">月卡会员</text>
|
||||
</view>
|
||||
<view class="stat-item">
|
||||
<text class="stat-value">{{ stats.yearly_count ?? 0 }}</text>
|
||||
<text class="stat-label">年卡会员</text>
|
||||
</view>
|
||||
<view class="stat-item">
|
||||
<text class="stat-value">{{ stats.trial_count ?? 0 }}</text>
|
||||
<text class="stat-label">试用中</text>
|
||||
</view>
|
||||
<view class="stat-item">
|
||||
<text class="stat-value">¥{{ stats.monthly_revenue ?? 0 }}</text>
|
||||
<text class="stat-label">本月收入</text>
|
||||
</view>
|
||||
</view>
|
||||
</view>
|
||||
|
||||
<DataTable
|
||||
:columns="columns"
|
||||
:records="records"
|
||||
:page="page"
|
||||
:totalPages="totalPages"
|
||||
@page="onPage"
|
||||
>
|
||||
<template #toolbar>
|
||||
<view class="tabs">
|
||||
<view class="tab" :class="{ active: activeTab === 'all' }" @click="onTabFilter('all')">全部订阅</view>
|
||||
<view class="tab" :class="{ active: activeTab === 'monthly' }" @click="onTabFilter('monthly')">月卡</view>
|
||||
<view class="tab" :class="{ active: activeTab === 'yearly' }" @click="onTabFilter('yearly')">年卡</view>
|
||||
<view class="tab" :class="{ active: activeTab === 'trial' }" @click="onTabFilter('trial')">试用中</view>
|
||||
<view class="tab" :class="{ active: activeTab === 'expired' }" @click="onTabFilter('expired')">已过期</view>
|
||||
</view>
|
||||
</template>
|
||||
|
||||
<template #rows>
|
||||
<view class="t-row" v-for="item in records" :key="item.id">
|
||||
<text class="t-td flex2">{{ item.nickname || item.user_id }}</text>
|
||||
<text class="t-td flex1">{{ planText(item.plan) }}</text>
|
||||
<text class="t-td flex1">¥{{ item.amount || '-' }}</text>
|
||||
<text class="t-td flex2">{{ formatDate(item.start_time) }}</text>
|
||||
<text class="t-td flex2">{{ formatDate(item.expire_time) }}</text>
|
||||
<text class="t-td flex1">
|
||||
<text :class="statusBadge(item.status)">{{ statusText(item.status) }}</text>
|
||||
</text>
|
||||
<text class="t-td flex1">
|
||||
<text class="action-link" @click="onDetail(item)">详情</text>
|
||||
<text class="action-link" v-if="item.status === 1" @click="onExtend(item)">延期</text>
|
||||
<text class="action-link" v-if="item.status === 1" @click="onCancel(item)">取消</text>
|
||||
<text class="action-link" v-if="item.status === 2 || item.status === 3" @click="onRenew(item)">续费</text>
|
||||
</text>
|
||||
</view>
|
||||
</template>
|
||||
</DataTable>
|
||||
|
||||
<ConfirmModal
|
||||
:visible="showCreate"
|
||||
title="创建订阅"
|
||||
confirmText="创建"
|
||||
:loading="creating"
|
||||
@close="showCreate = false"
|
||||
@confirm="onCreate"
|
||||
>
|
||||
<view class="form-group">
|
||||
<text class="form-label">用户ID</text>
|
||||
<input class="form-input" v-model="createForm.user_id" placeholder="输入用户ID" />
|
||||
</view>
|
||||
<view class="form-group">
|
||||
<text class="form-label">方案</text>
|
||||
<input class="form-input" v-model="createForm.plan" placeholder="monthly/quarterly/yearly" />
|
||||
</view>
|
||||
<view class="form-group">
|
||||
<text class="form-label">天数</text>
|
||||
<input class="form-input" v-model="createForm.days" placeholder="订阅天数" type="number" />
|
||||
</view>
|
||||
</ConfirmModal>
|
||||
</view>
|
||||
</template>
|
||||
|
||||
<script>
|
||||
import { get, post } from '../utils/request'
|
||||
import { exportCSV } from '../utils/export'
|
||||
import { formatDateShort } from '../utils/format'
|
||||
import { listMixin } from '../utils/useList'
|
||||
import DataTable from '../components/DataTable.vue'
|
||||
import ConfirmModal from '../components/ConfirmModal.vue'
|
||||
|
||||
var PLAN_MAP = { monthly: '月卡', quarterly: '季卡', yearly: '年卡', trial: '试用' }
|
||||
var STATUS_TEXT = { 1: '生效中', 2: '已过期', 3: '已取消' }
|
||||
var STATUS_BADGE = { 1: 'badge badge-success', 2: 'badge badge-error', 3: 'badge badge-default' }
|
||||
|
||||
export default {
|
||||
name: 'SubscriptionView',
|
||||
components: { DataTable, ConfirmModal },
|
||||
mixins: [listMixin('/api/v1/admin/subscriptions')],
|
||||
data() {
|
||||
return {
|
||||
stats: {},
|
||||
activeTab: 'all',
|
||||
columns: [
|
||||
{ key: 'user', label: '用户', flex: 2 },
|
||||
{ key: 'plan', label: '订阅类型', flex: 1 },
|
||||
{ key: 'amount', label: '订单金额', flex: 1 },
|
||||
{ key: 'start', label: '开始日期', flex: 2 },
|
||||
{ key: 'expire', label: '到期日期', flex: 2 },
|
||||
{ key: 'status', label: '状态', flex: 1 },
|
||||
{ key: 'actions', label: '操作', flex: 1 }
|
||||
],
|
||||
showCreate: false,
|
||||
creating: false,
|
||||
createForm: { user_id: '', plan: 'monthly', days: 30 }
|
||||
}
|
||||
},
|
||||
mounted() {
|
||||
this.reload()
|
||||
},
|
||||
methods: {
|
||||
reload() {
|
||||
this.loadList({ tab: this.activeTab })
|
||||
},
|
||||
onListLoaded(data) {
|
||||
if (data.stats) this.stats = data.stats
|
||||
},
|
||||
onTabFilter(tab) {
|
||||
this.activeTab = tab
|
||||
this.page = 1
|
||||
this.reload()
|
||||
},
|
||||
async onCreate() {
|
||||
this.creating = true
|
||||
try {
|
||||
await post('/api/v1/admin/subscriptions', {
|
||||
user_id: String(this.createForm.user_id).trim(),
|
||||
plan: this.createForm.plan,
|
||||
days: parseInt(this.createForm.days)
|
||||
})
|
||||
this.showCreate = false
|
||||
this.reload()
|
||||
uni.showToast({ title: '创建成功', icon: 'success' })
|
||||
} catch (e) {
|
||||
uni.showToast({ title: '创建失败', icon: 'none' })
|
||||
} finally {
|
||||
this.creating = false
|
||||
}
|
||||
},
|
||||
onDetail(item) {
|
||||
if (item.user_id) {
|
||||
this.$emit('navigate', 'user-detail', { user_id: String(item.user_id) })
|
||||
}
|
||||
},
|
||||
onExtend(item) {
|
||||
var self = this
|
||||
uni.showModal({
|
||||
title: '延期订阅',
|
||||
content: '为用户 #' + item.user_id + ' 延期 30 天?',
|
||||
success: async function (res) {
|
||||
if (res.confirm) {
|
||||
try {
|
||||
await post('/api/v1/admin/subscriptions', {
|
||||
user_id: String(item.user_id),
|
||||
plan: item.plan || 'monthly',
|
||||
days: 30
|
||||
})
|
||||
uni.showToast({ title: '延期成功', icon: 'success' })
|
||||
self.reload()
|
||||
} catch (e) {
|
||||
uni.showToast({ title: '操作失败', icon: 'none' })
|
||||
}
|
||||
}
|
||||
}
|
||||
})
|
||||
},
|
||||
onCancel(item) {
|
||||
var self = this
|
||||
uni.showModal({
|
||||
title: '取消订阅',
|
||||
content: '确定要取消用户 #' + item.user_id + ' 的订阅吗?',
|
||||
success: async function (res) {
|
||||
if (res.confirm) {
|
||||
try {
|
||||
await post('/api/v1/admin/subscriptions/cancel', {
|
||||
subscription_id: item.subscription_id
|
||||
})
|
||||
uni.showToast({ title: '已取消', icon: 'success' })
|
||||
self.reload()
|
||||
} catch (e) {
|
||||
uni.showToast({ title: '操作失败', icon: 'none' })
|
||||
}
|
||||
}
|
||||
}
|
||||
})
|
||||
},
|
||||
onRenew(item) {
|
||||
this.createForm.user_id = String(item.user_id || '')
|
||||
this.createForm.plan = item.plan || 'monthly'
|
||||
this.createForm.days = 30
|
||||
this.showCreate = true
|
||||
},
|
||||
planText(plan) { return PLAN_MAP[plan] || plan || '-' },
|
||||
statusText(status) { return STATUS_TEXT[status] || '-' },
|
||||
statusBadge(status) { return STATUS_BADGE[status] || 'badge badge-default' },
|
||||
formatDate: formatDateShort,
|
||||
async onExport() {
|
||||
try {
|
||||
var data = await get('/api/v1/admin/subscriptions', { page: 1, page_size: 9999, tab: this.activeTab })
|
||||
var records = data.records || []
|
||||
exportCSV('subscriptions_' + new Date().toISOString().slice(0, 10) + '.csv',
|
||||
['用户', '订阅类型', '订单金额', '开始日期', '到期日期', '状态'],
|
||||
records.map(function (r) {
|
||||
return [r.nickname || r.user_id || '', PLAN_MAP[r.plan] || r.plan || '', r.amount || '', r.start_time ? String(r.start_time).slice(0, 10) : '', r.expire_time ? String(r.expire_time).slice(0, 10) : '', STATUS_TEXT[r.status] || '']
|
||||
})
|
||||
)
|
||||
uni.showToast({ title: '导出成功', icon: 'success' })
|
||||
} catch (e) {
|
||||
uni.showToast({ title: '导出失败', icon: 'none' })
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
</script>
|
||||
|
||||
<style scoped>
|
||||
@import '../styles/common.css';
|
||||
|
||||
.stats-row {
|
||||
display: flex;
|
||||
gap: 16px;
|
||||
}
|
||||
|
||||
.stat-item {
|
||||
flex: 1;
|
||||
background: #fff;
|
||||
border-radius: 8px;
|
||||
padding: 16px;
|
||||
text-align: center;
|
||||
border: 1px solid #f0f0f0;
|
||||
}
|
||||
|
||||
.stat-value {
|
||||
display: block;
|
||||
font-size: 22px;
|
||||
font-weight: 700;
|
||||
color: #333;
|
||||
margin-bottom: 4px;
|
||||
}
|
||||
|
||||
.stat-label {
|
||||
font-size: 13px;
|
||||
color: #999;
|
||||
}
|
||||
|
||||
.tabs {
|
||||
display: flex;
|
||||
gap: 0;
|
||||
margin-bottom: 16px;
|
||||
border-bottom: 1px solid #f0f0f0;
|
||||
}
|
||||
|
||||
.tab {
|
||||
padding: 10px 20px;
|
||||
font-size: 14px;
|
||||
color: #666;
|
||||
cursor: pointer;
|
||||
border-bottom: 2px solid transparent;
|
||||
}
|
||||
|
||||
.tab.active {
|
||||
color: #E6508C;
|
||||
border-bottom-color: #E6508C;
|
||||
font-weight: 500;
|
||||
}
|
||||
|
||||
.action-link {
|
||||
margin-right: 8px;
|
||||
}
|
||||
</style>
|
||||
在新工单中引用
屏蔽一个用户