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

416 lines
15 KiB
Plaintext
Raw Permalink 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>
<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>