Initial commit of akmon project
This commit is contained in:
328
pages/mall/admin/subscription/user-subscriptions.uvue
Normal file
328
pages/mall/admin/subscription/user-subscriptions.uvue
Normal file
@@ -0,0 +1,328 @@
|
||||
<template>
|
||||
<view class="page">
|
||||
<view class="toolbar">
|
||||
<text class="title">用户订阅管理</text>
|
||||
</view>
|
||||
|
||||
<!-- 筛选器 -->
|
||||
<view class="filters">
|
||||
<view class="filter-item">
|
||||
<text class="label">用户ID</text>
|
||||
<input class="input" placeholder="输入用户ID(支持部分匹配)"
|
||||
:value="filters.userId"
|
||||
@input="(e:any)=>filters.userId=e.detail.value" />
|
||||
</view>
|
||||
<view class="filter-item">
|
||||
<text class="label">方案</text>
|
||||
<picker mode="selector" :range="planNames" :value="planIndex" @change="onPlanPick">
|
||||
<view class="picker-value">{{ planIndex===0 ? '全部' : planNames[planIndex] }}</view>
|
||||
</picker>
|
||||
</view>
|
||||
<view class="filter-item">
|
||||
<text class="label">状态</text>
|
||||
<picker mode="selector" :range="statusOptions" :value="statusIndex" @change="onStatusPick">
|
||||
<view class="picker-value">{{ statusOptions[statusIndex] }}</view>
|
||||
</picker>
|
||||
</view>
|
||||
<view class="filter-actions">
|
||||
<button class="btn" @click="search">查询</button>
|
||||
<button class="btn ghost" @click="reset">重置</button>
|
||||
</view>
|
||||
</view>
|
||||
|
||||
<!-- 列表头 -->
|
||||
<view class="table">
|
||||
<view class="row header">
|
||||
<text class="cell user">用户</text>
|
||||
<text class="cell plan">方案</text>
|
||||
<text class="cell status">状态</text>
|
||||
<text class="cell period">周期</text>
|
||||
<text class="cell renew">自动续订</text>
|
||||
<text class="cell cancel">终止到期</text>
|
||||
<text class="cell actions">操作</text>
|
||||
</view>
|
||||
<list-view>
|
||||
<list-item v-for="item in rows" :key="item['id']">
|
||||
<view class="row">
|
||||
<view class="cell user">
|
||||
<text class="mono">{{ shortUser(item['user_id']) }}</text>
|
||||
</view>
|
||||
<text class="cell plan">{{ (item['plan'] && item['plan']['name']) ? item['plan']['name'] : '—' }}</text>
|
||||
<text class="cell status">{{ mapStatus(item['status']) }}</text>
|
||||
<view class="cell period">
|
||||
<text class="mono">{{ fmtDate(item['start_date']) }} → {{ fmtDate(item['end_date']) || '—' }}</text>
|
||||
<text class="sub">下次扣费:{{ fmtDate(item['next_billing_date']) || '—' }}</text>
|
||||
</view>
|
||||
<view class="cell renew">
|
||||
<switch :checked="!!item['auto_renew']" @change="onToggleRenew(item, $event)" />
|
||||
</view>
|
||||
<view class="cell cancel">
|
||||
<switch :checked="!!item['cancel_at_period_end']" @change="onToggleCancelAtEnd(item, $event)" />
|
||||
</view>
|
||||
<view class="cell actions">
|
||||
<button size="mini" @click="openSetStatus(item)">设置状态</button>
|
||||
<button size="mini" type="warn" class="ml8" @click="terminateNow(item)">立即终止</button>
|
||||
</view>
|
||||
</view>
|
||||
</list-item>
|
||||
</list-view>
|
||||
</view>
|
||||
|
||||
<view v-if="!loading && rows.length === 0" class="empty">暂无数据</view>
|
||||
<view v-if="loading" class="loading">加载中...</view>
|
||||
|
||||
<!-- 状态选择 ActionSheet -->
|
||||
<view v-if="statusSheet.visible" class="overlay" @click.self="closeStatusSheet">
|
||||
<view class="sheet">
|
||||
<text class="sheet-title">设置订阅状态</text>
|
||||
<view class="sheet-list">
|
||||
<button v-for="s in statusUpdateList" :key="s" class="sheet-item" @click="applyStatus(s)">{{ mapStatus(s) }}</button>
|
||||
</view>
|
||||
<button class="sheet-cancel" @click="closeStatusSheet">取消</button>
|
||||
</view>
|
||||
</view>
|
||||
</view>
|
||||
</template>
|
||||
|
||||
<script lang="uts">
|
||||
import supa from '@/components/supadb/aksupainstance.uts'
|
||||
import { SUBSCRIPTION_STATUS } from '@/types/mall-types.uts'
|
||||
|
||||
type UJ = UTSJSONObject
|
||||
|
||||
export default {
|
||||
data() {
|
||||
return {
|
||||
loading: false as boolean,
|
||||
rows: [] as Array<UJ>,
|
||||
plans: [] as Array<UJ>,
|
||||
|
||||
// filters
|
||||
filters: {
|
||||
userId: '' as string,
|
||||
planId: '' as string,
|
||||
status: '' as string // '' means all
|
||||
},
|
||||
planNames: ['全部'] as Array<string>,
|
||||
planIds: [''] as Array<string>,
|
||||
planIndex: 0 as number,
|
||||
statusOptions: ['全部','试用','生效','待付款','已取消','已过期'] as Array<string>,
|
||||
statusValues: ['','trial','active','past_due','canceled','expired'] as Array<string>,
|
||||
statusIndex: 0 as number,
|
||||
|
||||
// status sheet
|
||||
statusSheet: {
|
||||
visible: false as boolean,
|
||||
id: '' as string,
|
||||
current: '' as string
|
||||
},
|
||||
statusUpdateList: ['trial','active','past_due','canceled','expired'] as Array<string>
|
||||
}
|
||||
},
|
||||
|
||||
onShow() {
|
||||
this.loadPlans()
|
||||
this.search()
|
||||
},
|
||||
|
||||
methods: {
|
||||
async loadPlans() {
|
||||
try {
|
||||
const res = await supa.from('ml_subscription_plans').select('id,name', {}).order('sort_order', { ascending: true }).execute()
|
||||
const arr = Array.isArray(res.data) ? (res.data as Array<UJ>) : []
|
||||
this.plans = arr
|
||||
this.planNames = ['全部']
|
||||
this.planIds = ['']
|
||||
for (let i=0;i<arr.length;i++) {
|
||||
this.planNames.push((arr[i]['name'] ?? '') as string)
|
||||
this.planIds.push((arr[i]['id'] ?? '') as string)
|
||||
}
|
||||
} catch (e) {
|
||||
// ignore
|
||||
}
|
||||
},
|
||||
|
||||
onPlanPick(e:any) {
|
||||
const idx = Number(e.detail.value)
|
||||
this.planIndex = isNaN(idx) ? 0 : idx
|
||||
this.filters.planId = this.planIds[this.planIndex]
|
||||
},
|
||||
onStatusPick(e:any) {
|
||||
const idx = Number(e.detail.value)
|
||||
this.statusIndex = isNaN(idx) ? 0 : idx
|
||||
this.filters.status = this.statusValues[this.statusIndex]
|
||||
},
|
||||
|
||||
async search() {
|
||||
try {
|
||||
this.loading = true
|
||||
let q = supa
|
||||
.from('ml_user_subscriptions')
|
||||
.select('*, plan:ml_subscription_plans(*)', {})
|
||||
.order('created_at', { ascending: false })
|
||||
|
||||
if (this.filters.userId && this.filters.userId.trim() !== '') {
|
||||
q = q.ilike('user_id', `%${this.filters.userId.trim()}%`)
|
||||
}
|
||||
if (this.filters.planId && this.filters.planId !== '') {
|
||||
q = q.eq('plan_id', this.filters.planId)
|
||||
}
|
||||
if (this.filters.status && this.filters.status !== '') {
|
||||
q = q.eq('status', this.filters.status)
|
||||
}
|
||||
|
||||
const res = await q.execute()
|
||||
this.rows = Array.isArray(res.data) ? (res.data as Array<UJ>) : []
|
||||
} catch (e) {
|
||||
uni.showToast({ title: '查询失败', icon: 'none' })
|
||||
} finally {
|
||||
this.loading = false
|
||||
}
|
||||
},
|
||||
|
||||
reset() {
|
||||
this.filters.userId = ''
|
||||
this.planIndex = 0
|
||||
this.statusIndex = 0
|
||||
this.filters.planId = ''
|
||||
this.filters.status = ''
|
||||
this.search()
|
||||
},
|
||||
|
||||
shortUser(id:any): string { const s = String(id ?? ''); return s.length>10 ? s.slice(0,6)+'…'+s.slice(-4) : s },
|
||||
fmtDate(s:any): string { if(!s) return ''; try { return new Date(String(s)).toISOString().slice(0,10) } catch { return '' } },
|
||||
mapStatus(s:any): string {
|
||||
const v = String(s ?? '')
|
||||
switch (v) {
|
||||
case 'trial': return '试用'
|
||||
case 'active': return '生效'
|
||||
case 'past_due': return '待付款'
|
||||
case 'canceled': return '已取消'
|
||||
case 'expired': return '已过期'
|
||||
default: return '未知'
|
||||
}
|
||||
},
|
||||
|
||||
async onToggleRenew(item: UJ, evt:any) {
|
||||
const id = (item['id'] ?? '') as string
|
||||
const newVal = evt?.detail?.value === true
|
||||
try {
|
||||
const res = await supa.from('ml_user_subscriptions').update({ auto_renew: newVal }).eq('id', id).execute()
|
||||
if (!(res.status>=200 && res.status<300)) {
|
||||
item['auto_renew'] = !newVal
|
||||
uni.showToast({ title: '更新失败', icon: 'none' })
|
||||
}
|
||||
} catch (e) {
|
||||
item['auto_renew'] = !newVal
|
||||
uni.showToast({ title: '请求失败', icon: 'none' })
|
||||
}
|
||||
},
|
||||
|
||||
async onToggleCancelAtEnd(item: UJ, evt:any) {
|
||||
const id = (item['id'] ?? '') as string
|
||||
const newVal = evt?.detail?.value === true
|
||||
try {
|
||||
const res = await supa.from('ml_user_subscriptions').update({ cancel_at_period_end: newVal }).eq('id', id).execute()
|
||||
if (!(res.status>=200 && res.status<300)) {
|
||||
item['cancel_at_period_end'] = !newVal
|
||||
uni.showToast({ title: '更新失败', icon: 'none' })
|
||||
}
|
||||
} catch (e) {
|
||||
item['cancel_at_period_end'] = !newVal
|
||||
uni.showToast({ title: '请求失败', icon: 'none' })
|
||||
}
|
||||
},
|
||||
|
||||
openSetStatus(item: UJ) {
|
||||
this.statusSheet.visible = true
|
||||
this.statusSheet.id = (item['id'] ?? '') as string
|
||||
this.statusSheet.current = (item['status'] ?? '') as string
|
||||
},
|
||||
closeStatusSheet() { this.statusSheet.visible = false },
|
||||
|
||||
async applyStatus(s: string) {
|
||||
const id = this.statusSheet.id
|
||||
try {
|
||||
const res = await supa.from('ml_user_subscriptions').update({ status: s }).eq('id', id).execute()
|
||||
if (res.status>=200 && res.status<300) {
|
||||
uni.showToast({ title: '已更新', icon: 'success' })
|
||||
this.closeStatusSheet()
|
||||
this.search()
|
||||
} else {
|
||||
uni.showToast({ title: '更新失败', icon: 'none' })
|
||||
}
|
||||
} catch (e) {
|
||||
uni.showToast({ title: '请求失败', icon: 'none' })
|
||||
}
|
||||
},
|
||||
|
||||
terminateNow(item: UJ) {
|
||||
const id = (item['id'] ?? '') as string
|
||||
if (!id) return
|
||||
uni.showModal({
|
||||
title: '确认终止',
|
||||
content: '将立即终止该订阅,并设置状态为已取消,是否继续?',
|
||||
success: async (r:any) => {
|
||||
if (r.confirm) {
|
||||
try {
|
||||
const now = new Date().toISOString()
|
||||
const res = await supa
|
||||
.from('ml_user_subscriptions')
|
||||
.update({ status: 'canceled', end_date: now, auto_renew: false })
|
||||
.eq('id', id)
|
||||
.execute()
|
||||
if (res.status>=200 && res.status<300) {
|
||||
uni.showToast({ title: '已终止', icon: 'success' })
|
||||
this.search()
|
||||
} else {
|
||||
uni.showToast({ title: '操作失败', icon: 'none' })
|
||||
}
|
||||
} catch (e) {
|
||||
uni.showToast({ title: '请求失败', icon: 'none' })
|
||||
}
|
||||
}
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
}
|
||||
</script>
|
||||
|
||||
<style scoped>
|
||||
.page { padding: 12px; }
|
||||
.toolbar { display: flex; align-items: center; justify-content: space-between; margin-bottom: 10px; }
|
||||
.title { font-size: 18px; font-weight: 600; }
|
||||
|
||||
.filters { background: #fff; border-radius: 10px; padding: 10px; display: grid; grid-template-columns: 1fr 1fr 1fr auto; gap: 10px; margin-bottom: 10px; }
|
||||
.filter-item {}
|
||||
.label { font-size: 12px; color: #666; margin-bottom: 4px; display: block; }
|
||||
.input { background: #f6f6f6; border-radius: 6px; padding: 8px; }
|
||||
.picker-value { background: #f6f6f6; border-radius: 6px; padding: 8px; color: #333; }
|
||||
.filter-actions { display: flex; gap: 8px; align-items: end; }
|
||||
.btn { background: #3cc51f; color: #fff; border-radius: 6px; padding: 8px 12px; }
|
||||
.btn.ghost { background: #f0f0f0; color: #333; }
|
||||
|
||||
.table { background: #fff; border-radius: 10px; overflow: hidden; }
|
||||
.row { display: flex; align-items: center; padding: 10px; }
|
||||
.row.header { background: #fafafa; font-weight: 600; color: #555; }
|
||||
.cell { padding: 0 6px; }
|
||||
.user { width: 18%; }
|
||||
.plan { width: 18%; }
|
||||
.status { width: 12%; }
|
||||
.period { width: 26%; display: flex; flex-direction: column; }
|
||||
.renew { width: 12%; display: flex; align-items: center; }
|
||||
.cancel { width: 12%; display: flex; align-items: center; }
|
||||
.actions { width: 22%; display: flex; justify-content: flex-end; }
|
||||
.ml8 { margin-left: 8px; }
|
||||
.mono { font-family: monospace; }
|
||||
.sub { color: #888; font-size: 12px; }
|
||||
.empty, .loading { padding: 24px; text-align: center; color: #888; }
|
||||
|
||||
/* ActionSheet */
|
||||
.overlay { position: fixed; inset: 0; background: rgba(0,0,0,0.45); display: flex; align-items: flex-end; }
|
||||
.sheet { background: #fff; width: 100%; border-top-left-radius: 12px; border-top-right-radius: 12px; padding: 12px; }
|
||||
.sheet-title { font-size: 16px; font-weight: 600; margin-bottom: 8px; }
|
||||
.sheet-list { display: flex; flex-direction: column; gap: 8px; }
|
||||
.sheet-item { background: #f6f6f6; color: #333; border-radius: 8px; padding: 10px; text-align: center; }
|
||||
.sheet-cancel { background: #eee; color: #333; border-radius: 8px; padding: 10px; margin-top: 8px; }
|
||||
</style>
|
||||
Reference in New Issue
Block a user