Files
akmon/pages/mall/admin/subscription/user-subscriptions.uvue
2026-01-20 08:04:15 +08:00

328 lines
12 KiB
Plaintext
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
<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>