Miniprogram: - Add back buttons to all custom-nav pages - Add debug/mock buttons (BLE connect, wear check, treatment) controlled by __DEV__ flag (hidden in prod) Admin console: - Fix log page using nonexistent fields (operator_type, target_type) now correctly reads admin_id/user_id/action/detail from API - Fix subscription date fields (started_at→start_time, expired_at→expire_time) - Wire up subscription detail/extend/renew action buttons - Wire up device detail "查看完整日志" button - Add "创建订阅" button to subscription toolbar - Fix subscription status mapping (2=expired, 3=cancelled) Docs: - Add detailed architecture docs for server, miniprogram, admin console
345 行
10 KiB
Vue
345 行
10 KiB
Vue
<template>
|
||
<AdminLayout currentPage="/pages/subscription/index">
|
||
<view class="toolbar">
|
||
<view class="header-actions">
|
||
<button class="btn-primary btn-sm" @click="showCreate = true">创建订阅</button>
|
||
<button class="btn-default btn-sm" @click="onExport">导出报表</button>
|
||
</view>
|
||
</view>
|
||
|
||
<view class="page-card">
|
||
<view class="stats-row">
|
||
<view class="stat-item">
|
||
<text class="stat-value">{{ stats.monthly_count ?? 0 }}</text>
|
||
<text class="stat-label">月卡会员</text>
|
||
</view>
|
||
<view class="stat-item">
|
||
<text class="stat-value">{{ stats.yearly_count ?? 0 }}</text>
|
||
<text class="stat-label">年卡会员</text>
|
||
</view>
|
||
<view class="stat-item">
|
||
<text class="stat-value">{{ stats.trial_count ?? 0 }}</text>
|
||
<text class="stat-label">试用中</text>
|
||
</view>
|
||
<view class="stat-item">
|
||
<text class="stat-value">¥{{ stats.monthly_revenue ?? 0 }}</text>
|
||
<text class="stat-label">本月收入</text>
|
||
</view>
|
||
</view>
|
||
</view>
|
||
|
||
<view class="page-card">
|
||
<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>
|
||
|
||
<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.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 === 2 || item.status === 3" @click="onRenew(item)">续费</text>
|
||
</text>
|
||
</view>
|
||
</view>
|
||
</view>
|
||
|
||
<view class="pagination">
|
||
<button class="btn-page" :disabled="page <= 1" @click="onPage(page - 1)">‹</button>
|
||
<button class="btn-page active">{{ page }}</button>
|
||
<button class="btn-page" :disabled="page >= totalPages" @click="onPage(page + 1)">›</button>
|
||
<text class="page-info">共 {{ totalPages }} 页</text>
|
||
</view>
|
||
</view>
|
||
|
||
<view 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 { exportCSV } from '../../utils/export'
|
||
import { formatDateShort } from '../../utils/format'
|
||
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 },
|
||
activeTab: 'all'
|
||
}
|
||
},
|
||
computed: {
|
||
totalPages() {
|
||
return Math.ceil(this.total / this.pageSize) || 1
|
||
}
|
||
},
|
||
onShow() {
|
||
this.loadSubscriptions()
|
||
},
|
||
methods: {
|
||
async loadSubscriptions() {
|
||
try {
|
||
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) {
|
||
uni.showToast({ title: '加载失败', icon: 'none' })
|
||
}
|
||
},
|
||
onTabFilter(tab) {
|
||
this.activeTab = tab
|
||
this.page = 1
|
||
this.loadSubscriptions()
|
||
},
|
||
onPage(p) {
|
||
this.page = p
|
||
this.loadSubscriptions()
|
||
},
|
||
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.loadSubscriptions()
|
||
uni.showToast({ title: '创建成功', icon: 'success' })
|
||
} catch (e) {
|
||
uni.showToast({ title: '创建失败', icon: 'none' })
|
||
} finally {
|
||
this.creating = false
|
||
}
|
||
},
|
||
onDetail(item) {
|
||
if (item.user_id) {
|
||
uni.navigateTo({ url: '/pages/user-detail/index?user_id=' + item.user_id })
|
||
}
|
||
},
|
||
onExtend(item) {
|
||
var self = this
|
||
uni.showModal({
|
||
title: '延期订阅',
|
||
content: '为用户 #' + item.user_id + ' 延期 30 天?',
|
||
success: async function (res) {
|
||
if (res.confirm) {
|
||
try {
|
||
await post('/api/v1/admin/subscriptions', {
|
||
user_id: String(item.user_id),
|
||
plan: item.plan || 'monthly',
|
||
days: 30
|
||
})
|
||
uni.showToast({ title: '延期成功', icon: 'success' })
|
||
self.loadSubscriptions()
|
||
} 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) {
|
||
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-error', 3: 'badge badge-default' }
|
||
return map[status] || 'badge badge-default'
|
||
},
|
||
formatDate: formatDateShort,
|
||
async onExport() {
|
||
try {
|
||
const data = await get('/api/v1/admin/subscriptions', { page: 1, page_size: 9999, tab: this.activeTab })
|
||
const records = data.records || []
|
||
var planMap = { monthly: '月卡', quarterly: '季卡', yearly: '年卡', trial: '试用' }
|
||
var statusMap = { 1: '生效中', 2: '试用中', 3: '已过期' }
|
||
exportCSV('subscriptions_' + new Date().toISOString().slice(0, 10) + '.csv',
|
||
['用户', '订阅类型', '订单金额', '开始日期', '到期日期', '状态'],
|
||
records.map(function (r) {
|
||
return [r.nickname || r.user_id || '', planMap[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) : '', statusMap[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;
|
||
}
|
||
|
||
.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;
|
||
}
|
||
|
||
.modal-title {
|
||
font-size: 18px;
|
||
font-weight: 600;
|
||
margin-bottom: 20px;
|
||
}
|
||
|
||
.form-group {
|
||
margin-bottom: 16px;
|
||
}
|
||
|
||
.form-label {
|
||
display: block;
|
||
font-size: 14px;
|
||
color: #333;
|
||
margin-bottom: 6px;
|
||
}
|
||
|
||
.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>
|