项目从akmon迁入到mall

This commit is contained in:
comlibmb
2026-01-21 12:12:22 +08:00
parent cf8236e175
commit d7f95f7fa5
165 changed files with 69160 additions and 0 deletions

View File

@@ -0,0 +1,416 @@
<template>
<view class="page">
<!-- 顶部操作栏 -->
<view class="toolbar">
<text class="title">订阅方案管理</text>
<button class="add-btn" @click="openCreate">新增方案</button>
</view>
<!-- 列表 -->
<view v-if="!loading && plans.length > 0" class="list">
<view class="row header">
<text class="cell code">编码</text>
<text class="cell name">名称</text>
<text class="cell price">价格</text>
<text class="cell period">周期</text>
<text class="cell active">启用</text>
<text class="cell sort">排序</text>
<text class="cell actions">操作</text>
</view>
<list-view>
<list-item v-for="item in plans" :key="item['id']">
<view class="row">
<text class="cell code">{{ item['plan_code'] }}</text>
<text class="cell name">{{ item['name'] }}</text>
<text class="cell price">{{ formatPrice(item['price']) }} {{ item['currency'] || 'CNY' }}</text>
<text class="cell period">{{ item['billing_period'] === 'yearly' ? '年付' : '月付' }}</text>
<view class="cell active">
<switch :checked="!!item['is_active']" @change="onActiveChange(item, $event)" />
</view>
<view class="cell sort">
<text>{{ item['sort_order'] ?? '-' }}</text>
</view>
<view class="cell actions">
<button size="mini" @click="openEdit(item)">编辑</button>
<button size="mini" type="warn" class="ml8" @click="confirmDelete(item)">删除</button>
</view>
</view>
</list-item>
</list-view>
</view>
<view v-if="!loading && plans.length === 0" class="empty">
<text>暂无订阅方案</text>
</view>
<view v-if="loading" class="loading"><text>加载中...</text></view>
<!-- 编辑弹层(纯样式实现,兼容 uni-app-x -->
<view v-if="editVisible" class="overlay" @click.self="closeEdit">
<view class="sheet">
<text class="sheet-title">{{ editMode === 'create' ? '新增方案' : '编辑方案' }}</text>
<scroll-view scroll-y="true" class="form">
<view class="form-item">
<text class="label">编码</text>
<input class="input" type="text" placeholder="例如: PRO_M"
:value="editForm.plan_code || ''"
@input="(e:any)=>editForm.plan_code=e.detail.value" />
</view>
<view class="form-item">
<text class="label">名称</text>
<input class="input" type="text" placeholder="例如: 专业版(月付)"
:value="editForm.name || ''"
@input="(e:any)=>editForm.name=e.detail.value" />
</view>
<view class="form-item">
<text class="label">描述</text>
<textarea class="textarea" placeholder="简要说明"
:value="editForm.description || ''"
@input="(e:any)=>editForm.description=e.detail.value" />
</view>
<view class="form-row">
<view class="form-item half">
<text class="label">价格</text>
<input class="input" type="number" placeholder="例如: 99"
:value="String(editForm.price ?? '')"
@input="(e:any)=>editForm.price=toNumber(e.detail.value)" />
</view>
<view class="form-item half">
<text class="label">币种</text>
<input class="input" type="text" placeholder="CNY / USD"
:value="editForm.currency || 'CNY'"
@input="(e:any)=>editForm.currency=e.detail.value" />
</view>
</view>
<view class="form-row">
<view class="form-item half">
<text class="label">周期</text>
<picker mode="selector" :range="periodOptions" :value="periodIndex"
@change="onPeriodPick">
<view class="picker-value">{{ editForm.billing_period === 'yearly' ? '年付' : '月付' }}</view>
</picker>
</view>
<view class="form-item half">
<text class="label">试用天数</text>
<input class="input" type="number" placeholder="例如: 14可留空"
:value="editForm.trial_days != null ? String(editForm.trial_days) : ''"
@input="(e:any)=>editForm.trial_days=toOptionalNumber(e.detail.value)" />
</view>
</view>
<view class="form-row">
<view class="form-item half">
<text class="label">排序</text>
<input class="input" type="number" placeholder="越小越靠前"
:value="String(editForm.sort_order ?? '')"
@input="(e:any)=>editForm.sort_order=toOptionalNumber(e.detail.value)" />
</view>
<view class="form-item half switch-item">
<text class="label">启用</text>
<switch :checked="!!editForm.is_active" @change="(e:any)=>editForm.is_active=e.detail.value===true" />
</view>
</view>
<view class="form-item">
<text class="label">功能点(每行一个)</text>
<textarea class="textarea" placeholder="示例:\n- 支持X\n- 提供Y"
:value="featuresText"
@input="(e:any)=>featuresText=e.detail.value" />
</view>
</scroll-view>
<view class="sheet-actions">
<button class="cancel" @click="closeEdit">取消</button>
<button class="save" @click="savePlan">保存</button>
</view>
</view>
</view>
</view>
</template>
<script lang="uts">
import supa from '@/components/supadb/aksupainstance.uts'
type UJ = UTSJSONObject
export default {
data() {
return {
loading: false as boolean,
plans: [] as Array<UJ>,
// 编辑弹层
editVisible: false as boolean,
editMode: 'create' as 'create' | 'edit',
editId: '' as string,
editForm: {
plan_code: '',
name: '',
description: '',
price: 0,
currency: 'CNY',
billing_period: 'monthly',
trial_days: null,
is_active: true,
sort_order: 0,
features: null
} as UJ,
featuresText: '' as string,
periodOptions: ['monthly','yearly'] as Array<string>,
periodIndex: 0 as number
}
},
onShow() {
this.loadPlans()
},
methods: {
async loadPlans() {
try {
this.loading = true
const res = await supa
.from('ml_subscription_plans')
.select('*', {})
.order('sort_order', { ascending: true })
.execute()
this.plans = Array.isArray(res.data) ? (res.data as Array<UJ>) : []
} catch (e) {
uni.showToast({ title: '加载失败', icon: 'none' })
} finally {
this.loading = false
}
},
formatPrice(v: any): string {
const n = Number(v)
if (isNaN(n)) return String(v ?? '')
return n.toFixed(2)
},
toNumber(v: any): number {
const n = Number(v)
return isNaN(n) ? 0 : n
},
toOptionalNumber(v: any): number | null {
if (v == null || String(v).trim() === '') return null
const n = Number(v)
return isNaN(n) ? null : n
},
openCreate() {
this.editMode = 'create'
this.editId = ''
this.editForm = {
plan_code: '',
name: '',
description: '',
price: 0,
currency: 'CNY',
billing_period: 'monthly',
trial_days: null,
is_active: true,
sort_order: 0,
features: null
} as UJ
this.featuresText = ''
this.periodIndex = 0
this.editVisible = true
},
openEdit(item: UJ) {
this.editMode = 'edit'
this.editId = (item['id'] ?? '') as string
// 拷贝主要字段
this.editForm = {
plan_code: item['plan_code'] ?? '',
name: item['name'] ?? '',
description: item['description'] ?? '',
price: item['price'] ?? 0,
currency: item['currency'] ?? 'CNY',
billing_period: item['billing_period'] ?? 'monthly',
trial_days: item['trial_days'] ?? null,
is_active: !!item['is_active'],
sort_order: item['sort_order'] ?? 0,
features: item['features'] ?? null
} as UJ
this.featuresText = this.featuresJsonToText(this.editForm['features'])
this.periodIndex = this.editForm['billing_period'] === 'yearly' ? 1 : 0
this.editVisible = true
},
closeEdit() {
this.editVisible = false
},
onPeriodPick(e: any) {
const idx = Number(e.detail.value)
this.periodIndex = isNaN(idx) ? 0 : idx
this.editForm['billing_period'] = this.periodOptions[this.periodIndex]
},
featuresTextToJson(text: string): UJ | null {
// 将每行转换为 { f1: '...', f2: '...' }
if (text == null || text.trim() === '') return null
const lines = text.split(/\r?\n/).map(s => s.trim()).filter(s => s.length > 0)
const obj: UJ = {} as any
for (let i = 0; i < lines.length; i++) {
const key = 'f' + (i + 1)
;(obj as any)[key] = lines[i]
}
return obj
},
featuresJsonToText(j: any): string {
if (j == null) return ''
const out: Array<string> = []
try {
const keys = Object.keys(j as any)
for (let i = 0; i < keys.length; i++) {
const k = keys[i]
const v = (j as any)[k]
out.push(typeof v === 'string' ? v : JSON.stringify(v))
}
} catch (e) {
// ignore
}
return out.join('\n')
},
async savePlan() {
// 校验
if (!this.editForm['plan_code'] || !this.editForm['name']) {
uni.showToast({ title: '编码和名称必填', icon: 'none' })
return
}
if (this.editForm['price'] == null || Number(this.editForm['price']) < 0) {
uni.showToast({ title: '价格需为非负数', icon: 'none' })
return
}
// features 处理
this.editForm['features'] = this.featuresTextToJson(this.featuresText)
try {
if (this.editMode === 'create') {
const res = await supa
.from('ml_subscription_plans')
.insert([this.editForm])
.execute()
if (res.status >= 200 && res.status < 300) {
uni.showToast({ title: '创建成功', icon: 'success' })
this.closeEdit()
this.loadPlans()
} else {
uni.showToast({ title: '创建失败', icon: 'none' })
}
} else {
const res = await supa
.from('ml_subscription_plans')
.update(this.editForm)
.eq('id', this.editId)
.execute()
if (res.status >= 200 && res.status < 300) {
uni.showToast({ title: '保存成功', icon: 'success' })
this.closeEdit()
this.loadPlans()
} else {
uni.showToast({ title: '保存失败', icon: 'none' })
}
}
} catch (e) {
uni.showToast({ title: '请求失败', icon: 'none' })
}
},
confirmDelete(item: UJ) {
const id = (item['id'] ?? '') as string
if (!id) return
uni.showModal({
title: '删除确认',
content: `确定删除方案「${item['name'] ?? ''}」吗?`,
success: (r:any) => {
if (r.confirm) this.deletePlan(id)
}
})
},
async deletePlan(id: string) {
try {
const res = await supa
.from('ml_subscription_plans')
.delete()
.eq('id', id)
.execute()
if (res.status >= 200 && res.status < 300) {
uni.showToast({ title: '已删除', icon: 'success' })
this.loadPlans()
} else {
uni.showToast({ title: '删除失败', icon: 'none' })
}
} catch (e) {
uni.showToast({ title: '请求失败', icon: 'none' })
}
},
async onActiveChange(item: UJ, evt: any) {
const id = (item['id'] ?? '') as string
const newVal = evt?.detail?.value === true
try {
const res = await supa
.from('ml_subscription_plans')
.update({ is_active: newVal })
.eq('id', id)
.execute()
if (!(res.status >= 200 && res.status < 300)) {
uni.showToast({ title: '更新失败', icon: 'none' })
// 回滚 UI
item['is_active'] = !newVal
}
} catch (e) {
uni.showToast({ title: '请求失败', icon: 'none' })
item['is_active'] = !newVal
}
}
}
}
</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; }
.add-btn { background: #3cc51f; color: #fff; border-radius: 6px; padding: 8px 12px; }
.list { 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; }
.code { width: 18%; }
.name { width: 22%; }
.price { width: 18%; }
.period { width: 12%; }
.active { width: 12%; display: flex; align-items: center; }
.sort { width: 8%; }
.actions { width: 20%; display: flex; justify-content: flex-end; }
.ml8 { margin-left: 8px; }
.empty, .loading { padding: 24px; text-align: center; color: #888; }
/* 弹层 */
.overlay { position: fixed; left: 0; right: 0; top: 0; bottom: 0; background: rgba(0,0,0,0.45); display: flex; align-items: flex-end; }
.sheet { background: #fff; width: 100%; max-height: 80%; border-top-left-radius: 12px; border-top-right-radius: 12px; padding: 12px; }
.sheet-title { font-size: 16px; font-weight: 600; margin-bottom: 8px; }
.form { max-height: 60vh; }
.form-item { margin-bottom: 12px; }
.form-row { display: flex; gap: 10px; }
.half { flex: 1; }
.label { font-size: 13px; color: #666; margin-bottom: 6px; display: block; }
.input { background: #f6f6f6; border-radius: 6px; padding: 8px; }
.textarea { background: #f6f6f6; border-radius: 6px; padding: 8px; min-height: 80px; }
.picker-value { background: #f6f6f6; border-radius: 6px; padding: 8px; color: #333; }
.switch-item { display: flex; align-items: center; justify-content: space-between; }
.sheet-actions { display: flex; justify-content: flex-end; gap: 10px; margin-top: 10px; }
.cancel { background: #f0f0f0; color: #333; border-radius: 6px; padding: 8px 12px; }
.save { background: #3cc51f; color: #fff; border-radius: 6px; padding: 8px 12px; }
</style>

View 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>